Skip to content
Draft
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
64 changes: 64 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# [REQUIRED] The hostname/domain pointed to
# the IP of the server running this service.
# SSL will automatically be set up and
# renewed with LetsEncrypt.
HOSTNAME=localhost.lan

# This hostname is where your JS is served out of
XSS_HOSTNAME=xss.localhost.lan
PORT=8181
# Control Panel
API_PORT=8888

# [REQUIRED] Email for SSL
SSL_CONTACT_EMAIL=admin@localhost.lan

# Maximum XSS callback payload size
# This includes the webpage screenshot, DOM HTML,
# page text, and other metadata. Note that if the
# payload is above this limit, you won't be notified
# of the XSS firing.
MAX_PAYLOAD_UPLOAD_SIZE_MB=50

# Whether or not to enable the web control panel
# Set to "false" or remove to disable the web UI.
# Useful for minimizing attack surface.
CONTROL_PANEL_ENABLED=true

# Whether or not to enable email notifications via
# SMTP for XSS payload fires.
SMTP_EMAIL_NOTIFICATIONS_ENABLED=false
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_USE_TLS=true
SMTP_USERNAME=YourEmail@gmail.com
SMTP_PASSWORD=YourEmailPassword
SMTP_FROM_EMAIL=YourEmail@gmail.com
SMTP_RECEIVER_EMAIL=YourEmail@gmail.com
###
#SENDGRID_API_KEY=

# CLIENT ID FOR OAUTH LOGIN
#CLIENT_ID=your_client_id
#CLIENT_SECRET=your_client_secret

# GENERATE A RANDOM LONG STRING FOR THIS
# USE `openssl rand -base64 64`
SESSION_SECRET_KEY=

# THERE IS NO NEED TO MODIFY BELOW THIS LINE
# ------------------------------------------
# FEEL FREE, BUT KNOW WHAT YOU'RE DOING.

# Where XSS screenshots are stored
SCREENSHOTS_DIR=/app/payload-fire-images
POSTGRES_USER=postgres
DATABASE_NAME=xsshunterexpress
DATABASE_USER=xsshunterexpress
DATABASE_PASSWORD=xsshunterexpress
DATABASE_HOST=postgresdb
NODE_ENV=development
USE_CLOUD_STORAGE=false
BUCKET_NAME=YourBucket
SENTRY_ENABLED=false
NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
*.env
.env
node_modules
*.bak
postgres-db-data/*
Expand All @@ -8,7 +9,6 @@ front-end/run.sh
.greenlockrc
greenlock.d/*
ssldata/*
config.env
payload-fire-images/*.gz
payload-fire-images/*.png
deploy.sh
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
FROM node:16-bullseye

RUN mkdir /app/
RUN mkdir /app/payload-fire-images
WORKDIR /app/
RUN npm install pm2 -g
RUN apt update; apt install -y libstdc++6
Expand All @@ -26,6 +27,7 @@ COPY utils.js /app/
COPY docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh
COPY templates /app/templates
RUN chown -R node:node /app

USER node

Expand Down
46 changes: 28 additions & 18 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const bcrypt = require('bcrypt');
const openpgp = require('openpgp');
const { Storage } = require('@google-cloud/storage');
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const path = require('path');
const uuid = require('uuid');
Expand Down Expand Up @@ -65,7 +66,8 @@ async function check_file_exists(file_path) {
}


async function set_up_api_server(app) {
async function get_api_server() {
const app = express();
// Check for existing session secret value
const session_secret_setting = process.env.SESSION_SECRET_KEY;

Expand All @@ -86,6 +88,8 @@ async function set_up_api_server(app) {
// Session management
app.use(session_wrapper_function);

app.use(bodyParser.json());

/* lol make this be a thing later TODO
// Limit how big uploads are
app.use(fileUpload({
Expand Down Expand Up @@ -173,25 +177,26 @@ async function set_up_api_server(app) {
});

app.get('/login', (req, res) => {
const client = new OAuth2Client(process.env.CLIENT_ID, process.env.CLIENT_SECRET, process.env.NODE_ENV == 'production' ? `https://${process.env.HOSTNAME}/oauth-login` : `http://${process.env.HOSTNAME}/oauth-login`);
/*const client = new OAuth2Client(process.env.CLIENT_ID, process.env.CLIENT_SECRET, process.env.NODE_ENV == 'production' ? `https://${process.env.HOSTNAME}/oauth-login` : `http://${process.env.HOSTNAME}/oauth-login`);
const authUrl = client.generateAuthUrl({
redirect_uri: process.env.NODE_ENV == 'production' ? `https://${process.env.HOSTNAME}/oauth-login` : `http://${process.env.HOSTNAME}/oauth-login`,
access_type: 'offline',
scope: ['email', 'profile'],
prompt: 'select_account'
});
res.redirect(authUrl);
});*/
res.redirect('/oauth-login');
});

app.get('/oauth-login', async (req, res) => {
const client = new OAuth2Client(process.env.CLIENT_ID, process.env.CLIENT_SECRET, process.env.NODE_ENV == 'production' ? `https://${process.env.HOSTNAME}/oauth-login` : `http://${process.env.HOSTNAME}/oauth-login`);
//const client = new OAuth2Client(process.env.CLIENT_ID, process.env.CLIENT_SECRET, process.env.NODE_ENV == 'production' ? `https://${process.env.HOSTNAME}/oauth-login` : `http://${process.env.HOSTNAME}/oauth-login`);
try{
const code = req.query.code;
/*const code = req.query.code;
const {tokens} = await client.getToken(code);
client.setCredentials(tokens);
const oauth2 = google.oauth2({version: 'v2', auth: client});
const googleUserProfile = await oauth2.userinfo.v2.me.get();
const email = googleUserProfile.data.email
const email = googleUserProfile.data.email*/
const email = 'user@localhost.lan'
const [user, created] = await Users.findOrCreate({ where: { 'email': email } });
if(created){
user.path = makeRandomPath(10);
Expand All @@ -218,20 +223,20 @@ async function set_up_api_server(app) {
return res.sendStatus(404);
}

const gz_image_path = `${screenshot_filename}.gz`;
const gz_image_filename = `${screenshot_filename}.gz`;

if (process.env.USE_CLOUD_STORAGE == "true"){
const storage = new Storage();

const bucket = storage.bucket(process.env.BUCKET_NAME);

const file = bucket.file(gz_image_path);
const file = bucket.file(gz_image_filename);
try {
// Download the gzipped image
const [image] = await file.download();
// Send the gzipped image in the response
res.set('Content-Encoding', 'gzip');
if(gz_image_path.endsWith(".b64png.enc.gz")){
if(gz_image_filename.endsWith(".b64png.enc.gz")){
res.set('Content-Type', 'text/plain');
res.setHeader('Content-disposition', 'attachment; filename=screenshot.b64png.enc');
res.send(image);
Expand All @@ -246,6 +251,7 @@ async function set_up_api_server(app) {
res.status(404).send(`Error retrieving image from GCS`);
}
}else{
const gz_image_path = `${SCREENSHOTS_DIR}/${gz_image_filename}`;
const image_exists = await check_file_exists(gz_image_path);

if(!image_exists) {
Expand All @@ -267,6 +273,10 @@ async function set_up_api_server(app) {
}
});

// add home route
app.get('/', async (req, res) => {
res.redirect("/app/");
});

// Serve the front-end
app.use('/app/', express.static(
Expand Down Expand Up @@ -405,21 +415,21 @@ async function set_up_api_server(app) {
if ( process.env.USE_CLOUD_STORAGE == "true"){
const storage = new Storage();
await Promise.all(screenshot_id_records.map(payload => {
let filename = ""
let filename = "";
if(payload.encrypted){
filename = `${payload.screenshot_id}.b64png.enc.gz`
filename = `${payload.screenshot_id}.b64png.enc.gz`;
}else{
filename = `${payload.screenshot_id}.png.gz`;
}
return storage.bucket(process.env.BUCKET_NAME).file(filename).delete();
}));
}else{
await Promise.all(screenshot_id_records.map(payload => {
let filename = "${SCREENSHOTS_DIR}/"
let filename = "";
if(payload.encrypted){
filename = `${payload.screenshot_id}.b64png.enc.gz`
filename = `${SCREENSHOTS_DIR}/${payload.screenshot_id}.b64png.enc.gz`;
}else{
fileName = `${payload.screenshot_id}.png.gz`;
filename = `${SCREENSHOTS_DIR}/${payload.screenshot_id}.png.gz`;
}
return asyncfs.unlink(filename);
}));
Expand Down Expand Up @@ -753,8 +763,8 @@ async function set_up_api_server(app) {
'result': {}
}).end();
});

return app;
}

module.exports = {
set_up_api_server
};
module.exports = get_api_server;
31 changes: 9 additions & 22 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,10 @@ async function get_app_server() {
"status": "success"
}).end();

if(req.get('host') != process.env.XSS_HOSTNAME) {
/*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");
Expand Down Expand Up @@ -349,8 +349,9 @@ async function get_app_server() {
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}`;
//TODO: Fix port handling and check if API Panel is required?
payload_fire_data.screenshot_url = `https://${process.env.XSS_HOSTNAME}/screenshots/${payload_fire_data.screenshot_id}.png`;
payload_fire_data.xsshunter_url = `https://${process.env.XSS_HOSTNAME}`;
await notification.send_email_notification(payload_fire_data, user.email);
}
});
Expand Down Expand Up @@ -380,10 +381,10 @@ async function get_app_server() {
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) {
/*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 } });
Expand All @@ -406,10 +407,10 @@ async function get_app_server() {
}
let xssURI = ""
if(process.env.NODE_ENV == "development"){
xssURI = `http://${process.env.XSS_HOSTNAME}`
xssURI = `http://${process.env.XSS_HOSTNAME}:${process.env.PORT}`
}else{

xssURI = `https://${process.env.XSS_HOSTNAME}`
xssURI = `https://${process.env.XSS_HOSTNAME}:${process.env.PORT}`
}

res.send(XSS_PAYLOAD.replace(
Expand All @@ -436,20 +437,6 @@ async function get_app_server() {
// 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;
Expand Down
31 changes: 31 additions & 0 deletions default.conf.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
server {
listen ${API_PORT} ssl;
server_name ${XSS_HOSTNAME};

ssl_certificate /etc/letsencrypt/live/${HOSTNAME}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${HOSTNAME}/privkey.pem;

location / {
proxy_pass http://xsshunterexpress:${API_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

server {
listen ${PORT} ssl;
server_name ${XSS_HOSTNAME};

ssl_certificate /etc/letsencrypt/live/${HOSTNAME}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${HOSTNAME}/privkey.pem;

location / {
proxy_pass http://xsshunterexpress:${PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
34 changes: 23 additions & 11 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,32 +1,44 @@
version: "3.9"
services:
# XSS Hunter Express service
xsshunterexpress:
build: .
env_file:
- dev.env
ports:
- "127.0.0.1:8080:8080"
- .env
volumes:
# Directory where payload fire images are stored.
- ./payload-fire-images:/app/payload-fire-images
- payload-fire-images:/app/payload-fire-images:rw
- ~/.config/gcloud/application_default_credentials.json:/gcloud.json:ro
depends_on:
postgresdb:
condition: service_healthy
volumes:
- ~/.config/gcloud/application_default_credentials.json:/gcloud.json
postgresdb:
image: postgres
image: postgres:16
restart: always
env_file:
- dev.env
- .env
environment:
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_HOST_AUTH_METHOD: trust
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 3s
interval: 5s
timeout: 5s
retries: 5
volumes:
- ./postgres-db-data:/var/lib/postgresql/data/pgdata
- postgres-db-data:/var/lib/postgresql/data/pgdata:rw
nginx:
image: nginx:stable
restart: always
env_file:
- .env
ports:
- "${API_PORT}:${API_PORT}"
- "${PORT}:${PORT}"
volumes:
- ./default.conf.template:/etc/nginx/templates/default.conf.template:ro

volumes:
payload-fire-images:
postgres-db-data:
caddy-config:
caddy-data:
Loading