mirror of
https://github.com/trufflesecurity/xsshunter
synced 2024-12-18 00:33:04 +00:00
7fb21d7adf
* client gpg * first pass on pgp * completing backend save * displaying encrypted data * add migrations * fixing the download button * fixing image download * fixing image download * fixing image download * fixing email template bugs * fixing download button * final touches * final touches * final touches * final touches * final touches * Update Settings.vue * final touches * final touches * final touches * add sentry logging and stream file --------- Co-authored-by: counter <counter@counters-MacBook-Air.local> Co-authored-by: Dustin Decker <dustin@trufflesec.com>
458 lines
15 KiB
JavaScript
458 lines
15 KiB
JavaScript
const bodyParser = require('body-parser');
|
||
const { Storage } = require('@google-cloud/storage');
|
||
const express = require('express');
|
||
const fs = require('fs');
|
||
const zlib = require('zlib');
|
||
const path = require('path');
|
||
const asyncfs = require('fs').promises;
|
||
const uuid = require('uuid');
|
||
const database = require('./database.js');
|
||
const PayloadFireResults = database.PayloadFireResults;
|
||
const savePayload = database.savePayload;
|
||
const Users = database.Users;
|
||
const CollectedPages = database.CollectedPages;
|
||
const InjectionRequests = database.InjectionRequests;
|
||
const sequelize = database.sequelize;
|
||
const notification = require('./notification.js');
|
||
const api = require('./api.js');
|
||
const validate = require('express-jsonschema').validate;
|
||
const constants = require('./constants.js');
|
||
const Sentry = require('@sentry/node');
|
||
const Tracing = require("@sentry/tracing");
|
||
const Profiling = require("@sentry/profiling-node");
|
||
|
||
function set_secure_headers(req, res) {
|
||
res.set("X-XSS-Protection", "mode=block");
|
||
res.set("X-Content-Type-Options", "nosniff");
|
||
res.set("X-Frame-Options", "deny");
|
||
|
||
if (req.path.startsWith(constants.API_BASE_PATH)) {
|
||
res.set("Content-Security-Policy", "default-src 'none'; script-src 'none'");
|
||
res.set("Content-Type", "application/json");
|
||
return
|
||
}
|
||
}
|
||
|
||
async function check_file_exists(file_path) {
|
||
return asyncfs.access(file_path, fs.constants.F_OK).then(() => {
|
||
return true;
|
||
}).catch(() => {
|
||
return false;
|
||
});
|
||
}
|
||
|
||
// Load XSS payload from file into memory
|
||
const XSS_PAYLOAD = fs.readFileSync(
|
||
'./probe.js',
|
||
'utf8'
|
||
);
|
||
|
||
var multer = require('multer');
|
||
var upload = multer({ dest: '/tmp/' })
|
||
const SCREENSHOTS_DIR = path.resolve(process.env.SCREENSHOTS_DIR);
|
||
|
||
async function get_app_server() {
|
||
const app = express();
|
||
|
||
if (process.env.SENTRY_ENABLED === "true") {
|
||
Sentry.init({
|
||
dsn: process.env.SENTRY_DSN,
|
||
integrations: [
|
||
// enable HTTP calls tracing
|
||
new Sentry.Integrations.Http({ tracing: true }),
|
||
// enable Express.js middleware tracing
|
||
new Tracing.Integrations.Express({ app }),
|
||
// add beta profiling integration
|
||
new Profiling.ProfilingIntegration()
|
||
],
|
||
// 1.0 is 100% capture rate
|
||
profilesSampleRate: 1.0,
|
||
tracesSampleRate: 0.01,
|
||
});
|
||
|
||
// RequestHandler creates a separate execution context using domains, so that every
|
||
// transaction/span/breadcrumb is attached to its own Hub instance
|
||
app.use(Sentry.Handlers.requestHandler());
|
||
// TracingHandler creates a trace for every incoming request
|
||
app.use(Sentry.Handlers.tracingHandler());
|
||
app.use(Sentry.Handlers.errorHandler());
|
||
}
|
||
|
||
app.set('trust proxy', true);
|
||
app.disable('x-powered-by');
|
||
|
||
// I have a question for Express:
|
||
// https://youtu.be/ZtjFsQBuJWw?t=4
|
||
app.set('case sensitive routing', true);
|
||
|
||
app.use(bodyParser.json());
|
||
|
||
// Set security-related headers on requests
|
||
app.use(async function(req, res, next) {
|
||
set_secure_headers(req, res);
|
||
next();
|
||
});
|
||
|
||
// Handler for HTML pages collected by payloads
|
||
const CollectedPagesCallbackSchema = {
|
||
"type": "object",
|
||
"properties": {
|
||
"uri": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"html": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
}
|
||
};
|
||
app.post('/page_callback', upload.none(), validate({body: CollectedPagesCallbackSchema}), async (req, res) => {
|
||
res.set("Access-Control-Allow-Origin", "*");
|
||
res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||
res.set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
|
||
res.set("Access-Control-Max-Age", "86400");
|
||
|
||
const page_insert_response = await CollectedPages.create({
|
||
id: uuid.v4(),
|
||
uri: req.body.uri,
|
||
html: req.body.html,
|
||
});
|
||
|
||
// Send the response immediately, they don't need to wait for us to store everything.
|
||
res.status(200).json({
|
||
"status": "success"
|
||
}).end();
|
||
});
|
||
|
||
// Handler for XSS payload data to be received
|
||
const JSCallbackSchema = {
|
||
"type": "object",
|
||
"properties": {
|
||
"uri": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"cookies": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"referrer": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"user-agent": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"browser-time": {
|
||
"type": "string",
|
||
"default": "0",
|
||
"pattern": "^\\d+$"
|
||
},
|
||
"probe-uid": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"origin": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"injection_key": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"title": {
|
||
"type": "string",
|
||
"default": ""
|
||
},
|
||
"was_iframe": {
|
||
"type": "string",
|
||
"default": "false",
|
||
"enum": ["true", "false"]
|
||
},
|
||
"secrets": {
|
||
"type": "string",
|
||
"default": []
|
||
},
|
||
"CORS": {
|
||
"type": "string",
|
||
"default": []
|
||
},
|
||
"gitExposed": {
|
||
"type": "string",
|
||
"default": []
|
||
},
|
||
"path": {
|
||
"type": "string",
|
||
"default": ""
|
||
}
|
||
}
|
||
};
|
||
app.post('/js_callback', upload.single('screenshot'), validate({body: JSCallbackSchema}), async (req, res) => {
|
||
res.set("Access-Control-Allow-Origin", "*");
|
||
res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||
res.set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
|
||
res.set("Access-Control-Max-Age", "86400");
|
||
|
||
// Send the response immediately, they don't need to wait for us to store everything.
|
||
res.status(200).json({
|
||
"status": "success"
|
||
}).end();
|
||
|
||
if(req.get('host') != process.env.XSS_HOSTNAME) {
|
||
console.debug(`got bad host ${req.get('host')}`);
|
||
return res.redirect("/app/")
|
||
}
|
||
const userPath = req.body.path;
|
||
if (!userPath){
|
||
console.debug("req had no user path ID");
|
||
return
|
||
}
|
||
|
||
const user = await Users.findOne({ where: { 'path': userPath } });
|
||
|
||
if (user === null){
|
||
console.debug("No user found for path provided");
|
||
return
|
||
}
|
||
|
||
console.debug(`Got payload for user id ${user.id}`);
|
||
|
||
const userID = user.id;
|
||
let encrypted = false;
|
||
if ("encrypted_data" in req.body){
|
||
encrypted = true;
|
||
}
|
||
|
||
// 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();
|
||
let payload_fire_image_filename = "";
|
||
let filename_in_bucket = "";
|
||
if(!encrypted){
|
||
payload_fire_image_filename = `${SCREENSHOTS_DIR}/${payload_fire_image_id}.png.gz`;
|
||
filename_in_bucket = `${payload_fire_image_id}.png.gz`;
|
||
}else{
|
||
payload_fire_image_filename = `${SCREENSHOTS_DIR}/${payload_fire_image_id}.b64png.enc.gz`;
|
||
filename_in_bucket = `${payload_fire_image_id}.b64png.enc.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.
|
||
if (process.env.USE_CLOUD_STORAGE == "true"){
|
||
const storage = new Storage();
|
||
//creating a bucket instance
|
||
const bucket = storage.bucket(process.env.BUCKET_NAME);
|
||
//compressing the file using gzip
|
||
const gzip = zlib.createGzip();
|
||
const gzipTempFileName = multer_temp_image_path + ".gz";
|
||
const tempFileWriteStream = fs.createWriteStream(gzipTempFileName);
|
||
input_read_stream.pipe(gzip).pipe(tempFileWriteStream);
|
||
// Wait for the file to be finished writing
|
||
await new Promise((resolve, reject) => {
|
||
tempFileWriteStream.on('finish', resolve);
|
||
tempFileWriteStream.on('error', reject);
|
||
});
|
||
//uploading the gzipped file to GCS
|
||
await bucket.upload(gzipTempFileName, {
|
||
gzip: true,
|
||
destination: filename_in_bucket,
|
||
metadata: {
|
||
cacheControl: 'public, max-age=31536000',
|
||
},
|
||
});
|
||
console.debug(`${payload_fire_image_id}.png.gz has been uploaded to GCS.`);
|
||
await asyncfs.unlink(multer_temp_image_path);
|
||
await asyncfs.unlink(gzipTempFileName);
|
||
}else{
|
||
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.debug(`Gzip stream complete, deleting multer temp file: ${multer_temp_image_path}`);
|
||
|
||
await asyncfs.unlink(multer_temp_image_path);
|
||
});
|
||
}
|
||
const payload_fire_id = uuid.v4();
|
||
let payload_fire_data = {}
|
||
if(encrypted){
|
||
if (req.body.encrypted_data.length > 100000){
|
||
Sentry.captureMessage(`encrypted data length too long: ${req.body.encrypted_data.length}`);
|
||
return res.status(400).json({
|
||
"status": "error encrypted data length too long"
|
||
}).end();
|
||
}else if (req.body.pgp_key.length > 100000){
|
||
Sentry.captureMessage(`public key length too long: ${req.body.pgp_key.length}`);
|
||
return res.status(400).json({
|
||
"status": "error public key length too long"
|
||
}).end();
|
||
}
|
||
payload_fire_data = {
|
||
id: payload_fire_id,
|
||
user_id: userID,
|
||
encrypted: true,
|
||
encrypted_data: req.body.encrypted_data,
|
||
screenshot_id: payload_fire_image_id,
|
||
public_key: req.body.pgp_key
|
||
}
|
||
}else{
|
||
payload_fire_data = {
|
||
id: payload_fire_id,
|
||
user_id: userID,
|
||
url: req.body.uri,
|
||
encrypted: false,
|
||
ip_address: req.ip,
|
||
referer: req.body.referrer,
|
||
user_agent: req.body['user-agent'],
|
||
cookies: req.body.cookies,
|
||
title: req.body.title,
|
||
secrets: JSON.parse(req.body.secrets),
|
||
origin: req.body.origin,
|
||
screenshot_id: payload_fire_image_id,
|
||
was_iframe: (req.body.was_iframe === 'true'),
|
||
browser_timestamp: parseInt(req.body['browser-time']),
|
||
correlated_request: 'No correlated request found for this injection.',
|
||
}
|
||
if (req.body.CORS != "false"){
|
||
payload_fire_data.CORS = req.body.CORS;
|
||
}
|
||
if (req.body.gitExposed != "false"){
|
||
payload_fire_data.gitExposed = req.body.gitExposed.substring(0,5000);
|
||
}
|
||
}
|
||
|
||
|
||
// Check for correlated request
|
||
const correlated_request_rec = await InjectionRequests.findOne({
|
||
where: {
|
||
injection_key: req.body.injection_key
|
||
}
|
||
});
|
||
|
||
if(correlated_request_rec) {
|
||
payload_fire_data.correlated_request = correlated_request_rec.request;
|
||
}
|
||
|
||
// Store payload fire results in the database
|
||
const new_payload_fire_result = await database.savePayload(payload_fire_data);
|
||
|
||
console.log(`Saved result for user id ${userID}`);
|
||
// Send out notification via configured notification channel
|
||
if(user.sendEmailAlerts && process.env.EMAIL_NOTIFICATIONS_ENABLED=="true") {
|
||
payload_fire_data.screenshot_url = `https://${process.env.HOSTNAME}/screenshots/${payload_fire_data.screenshot_id}.png`;
|
||
payload_fire_data.xsshunter_url = `https://${process.env.HOSTNAME}`;
|
||
await notification.send_email_notification(payload_fire_data, user.email);
|
||
}
|
||
});
|
||
|
||
|
||
// Set up /health handler so the user can
|
||
// do uptime checks and appropriate alerting.
|
||
app.get('/health', async (req, res) => {
|
||
try {
|
||
await sequelize.authenticate();
|
||
res.status(200).json({
|
||
"status": "ok"
|
||
}).end();
|
||
} catch (error) {
|
||
console.error('An error occurred when testing the database connection (/health):', error);
|
||
res.status(500).json({
|
||
"status": "error"
|
||
}).end();
|
||
}
|
||
});
|
||
|
||
const payload_handler = async (req, res) => {
|
||
res.set("Content-Security-Policy", "default-src 'none'; script-src 'none'");
|
||
res.set("Content-Type", "application/javascript");
|
||
res.set("Access-Control-Allow-Origin", "*");
|
||
res.set("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||
res.set("Access-Control-Allow-Headers", "Content-Type, X-Requested-With");
|
||
res.set("Access-Control-Max-Age", "86400");
|
||
|
||
if(req.get('host') != process.env.XSS_HOSTNAME) {
|
||
console.debug(req.get('host'));
|
||
return res.redirect("/app/");
|
||
}
|
||
|
||
const userPath = req.originalUrl.split("/")[1];
|
||
const user = await Users.findOne({ where: { 'path': userPath } });
|
||
|
||
if (user === null){
|
||
console.debug(`No user found for path ${userPath}`);
|
||
return res.send("Hey");
|
||
}
|
||
let pgp_key = user.pgp_key;
|
||
|
||
if (! pgp_key){
|
||
pgp_key = "";
|
||
}
|
||
|
||
console.log(`Got xss fetch for user id ${user.id}`);
|
||
|
||
let chainload_uri = user.additionalJS;
|
||
if (! chainload_uri){
|
||
chainload_uri = '';
|
||
}
|
||
let xssURI = ""
|
||
if(process.env.NODE_ENV == "development"){
|
||
xssURI = `http://${process.env.XSS_HOSTNAME}`
|
||
}else{
|
||
|
||
xssURI = `https://${process.env.XSS_HOSTNAME}`
|
||
}
|
||
|
||
res.send(XSS_PAYLOAD.replace(
|
||
/\[HOST_URL\]/g,
|
||
xssURI
|
||
).replace(
|
||
'[COLLECT_PAGE_LIST_REPLACE_ME]',
|
||
JSON.stringify([])
|
||
).replace(
|
||
/\[USER_PATH\]/g,
|
||
userPath
|
||
).replace(
|
||
'[pgp_key]',
|
||
pgp_key
|
||
).replace(
|
||
'[CHAINLOAD_REPLACE_ME]',
|
||
JSON.stringify(chainload_uri)
|
||
).replace(
|
||
'[PROBE_ID]',
|
||
JSON.stringify(req.params.probe_id)
|
||
));
|
||
};
|
||
|
||
// Handler that returns the XSS payload at the base path
|
||
app.get('/', payload_handler);
|
||
|
||
/*
|
||
Enabling the web control panel is 100% optional. This can be
|
||
enabled with the "CONTROL_PANEL_ENABLED" environment variable.
|
||
|
||
However, if the user just wants alerts on payload firing then
|
||
they can disable the web control panel to reduce attack surface.
|
||
*/
|
||
if(process.env.CONTROL_PANEL_ENABLED === 'true') {
|
||
// Enable API and static asset serving.
|
||
await api.set_up_api_server(app);
|
||
} else {
|
||
console.log(`[INFO] Control panel NOT enabled. Not serving API or GUI server, only acting as a notification server...`);
|
||
}
|
||
|
||
app.get('/:probe_id', payload_handler);
|
||
|
||
return app;
|
||
}
|
||
|
||
module.exports = get_app_server;
|