Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
front-end/node_modules
front-end/dist
payload-fire-images
postgres-db-data
.git
.serena
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ ssldata/*
config.env
payload-fire-images/*.gz
payload-fire-images/*.png
front-end/node_modules/
.serena/
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
79 changes: 46 additions & 33 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async function get_app_server() {
// Making 100% sure this works like it should
// https://youtu.be/aCbfMkh940Q?t=6
app.use(async function(req, res, next) {
if(req.path.toLowerCase() === req.path) {
if(req.path.startsWith('/admin/') || req.path.startsWith('/screenshots/') || req.path.toLowerCase() === req.path) {
next();
return
}
Expand Down 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