Skip to content
Open
13 changes: 9 additions & 4 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ async function set_up_api_server(app) {
}
}
app.delete(constants.API_BASE_PATH + 'payloadfires', validate({ body: DeletePayloadFiresSchema }), async (req, res) => {
try {
const ids_to_delete = req.body.ids;

// Pull the corresponding screenshot_ids from the DB so
Expand All @@ -222,11 +223,11 @@ async function set_up_api_server(app) {
},
attributes: ['id', 'screenshot_id']
});
const screenshots_to_delete = screenshot_id_records.map(payload => {
return `${SCREENSHOTS_DIR}/${payload.screenshot_id}.png.gz`;
});
const screenshots_to_delete = screenshot_id_records
.filter(payload => payload.screenshot_id !== null)
.map(payload => `${SCREENSHOTS_DIR}/${payload.screenshot_id}.png.gz`);
await Promise.all(screenshots_to_delete.map(screenshot_path => {
return asyncfs.unlink(screenshot_path);
return asyncfs.unlink(screenshot_path).catch(() => {});
}));
const payload_fires = await PayloadFireResults.destroy({
where: {
Expand All @@ -240,6 +241,10 @@ async function set_up_api_server(app) {
'success': true,
'result': {}
}).end();
} catch(e) {
console.error('Error deleting payload fires:', e);
res.status(500).json({ 'success': false, 'error': e.message }).end();
}
});

/*
Expand Down
77 changes: 45 additions & 32 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,36 +176,39 @@ async function get_app_server() {
"status": "success"
}).end();

// Multer stores the image in the /tmp/ dir. We use this source image
// to write a gzipped version in the user-provided dir and then delete
// the original uncompressed image.
const payload_fire_image_id = uuid.v4();
const payload_fire_image_filename = `${SCREENSHOTS_DIR}/${payload_fire_image_id}.png.gz`;
const multer_temp_image_path = req.file.path;

// We also gzip the image so we don't waste disk space
const gzip = zlib.createGzip();
const output_gzip_stream = fs.createWriteStream(payload_fire_image_filename);
const input_read_stream = fs.createReadStream(multer_temp_image_path);

// When the "finish" event is called we delete the original
// uncompressed image file left behind by multer.
input_read_stream.pipe(gzip).pipe(output_gzip_stream).on('finish', async (error) => {
if(error) {
console.error(`An error occurred while writing the XSS payload screenshot (gzipped) to disk:`);
console.error(error);
}

console.log(`Gzip stream complete, deleting multer temp file: ${multer_temp_image_path}`);

await asyncfs.unlink(multer_temp_image_path);
});
// Screenshot is optional — the probe omits it when html2canvas fails
// (SVG-heavy pages, broken images, safety-timer fallback). Guard req.file
// so a missing screenshot doesn't crash the handler and lose all other data.
let payload_fire_image_id = null;
if (req.file) {
payload_fire_image_id = uuid.v4();
const payload_fire_image_filename = `${SCREENSHOTS_DIR}/${payload_fire_image_id}.png.gz`;
const multer_temp_image_path = req.file.path;

// We also gzip the image so we don't waste disk space
const gzip = zlib.createGzip();
const output_gzip_stream = fs.createWriteStream(payload_fire_image_filename);
const input_read_stream = fs.createReadStream(multer_temp_image_path);

// When the "finish" event is called we delete the original
// uncompressed image file left behind by multer.
input_read_stream.pipe(gzip).pipe(output_gzip_stream).on('finish', async (error) => {
if(error) {
console.error(`An error occurred while writing the XSS payload screenshot (gzipped) to disk:`);
console.error(error);
}

console.log(`Gzip stream complete, deleting multer temp file: ${multer_temp_image_path}`);

await asyncfs.unlink(multer_temp_image_path);
});
}

const payload_fire_id = uuid.v4();
var payload_fire_data = {
id: payload_fire_id,
url: req.body.uri,
ip_address: req.connection.remoteAddress.toString(),
ip_address: (req.ip || req.connection.remoteAddress).toString(),
referer: req.body.referrer,
user_agent: req.body['user-agent'],
cookies: req.body.cookies,
Expand All @@ -230,13 +233,23 @@ async function get_app_server() {
payload_fire_data.correlated_request = correlated_request_rec.request;
}

// Store payload fire results in the database
const new_payload_fire_result = await PayloadFireResults.create(payload_fire_data);

// Send out notification via configured notification channel
if(process.env.SMTP_EMAIL_NOTIFICATIONS_ENABLED === "true") {
payload_fire_data.screenshot_url = `https://${process.env.HOSTNAME}/screenshots/${payload_fire_data.screenshot_id}.png`;
await notification.send_email_notification(payload_fire_data);
// The HTTP response has already been sent above, so this runs
// fire-and-forget. Any rejection here (e.g. a DB error) can't be
// surfaced to the client and would otherwise become an
// UnhandledPromiseRejection that can crash the process — so catch
// and log it instead.
try {
// Store payload fire results in the database
const new_payload_fire_result = await PayloadFireResults.create(payload_fire_data);

// Send out notification via configured notification channel
if(process.env.SMTP_EMAIL_NOTIFICATIONS_ENABLED === "true") {
payload_fire_data.screenshot_url = `https://${process.env.HOSTNAME}/screenshots/${payload_fire_data.screenshot_id}.png`;
await notification.send_email_notification(payload_fire_data);
}
} catch(error) {
console.error(`Failed to persist/notify XSS payload fire (id: ${payload_fire_id}):`);
console.error(error);
}
});

Expand Down
41 changes: 13 additions & 28 deletions database.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,50 +156,35 @@ PayloadFireResults.init({
}, {
sequelize,
modelName: 'payload_fire_results',
// NOTE: The unbounded free-text columns (url, referer, user_agent,
// cookies, title, origin) are intentionally NOT indexed. A Postgres
// btree index entry cannot exceed ~8191 bytes, and these columns hold
// unbounded attacker/victim-supplied data (a large cookie/referer/url
// would make the whole INSERT fail with "index row requires N bytes").
// Only fixed-size or bounded columns are indexed. If search-by-origin is
// ever needed, use a hash index (USING hash) rather than btree.
indexes: [
{
unique: false,
fields: ['url'],
method: 'BTREE',
},
{
// Bounded (socket peer IP, ~45 chars max) and useful for
// correlating fires from the same source.
unique: false,
fields: ['ip_address'],
method: 'BTREE',
},
{
unique: false,
fields: ['referer'],
method: 'BTREE',
},
{
unique: false,
fields: ['user_agent'],
method: 'BTREE',
},
{
unique: false,
fields: ['cookies'],
method: 'BTREE',
},
{
unique: false,
fields: ['title'],
method: 'BTREE',
},
{
unique: false,
fields: ['origin'],
fields: ['was_iframe'],
method: 'BTREE',
},
{
unique: false,
fields: ['was_iframe'],
fields: ['browser_timestamp'],
method: 'BTREE',
},
{
// Used by the payload-fire list ordering (ORDER BY createdAt DESC).
unique: false,
fields: ['browser_timestamp'],
fields: ['createdAt'],
method: 'BTREE',
}
]
Expand Down
Loading