Skip to content
Merged
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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ This vulnerable application contains the following API/Web Service vulnerabiliti
* GraphQL Introspection Enabled
* GraphQL Arbitrary File Write
* GraphQL Batching Brute Force
* API Endpoint Brute Forcing
* CRLF Injection
* XML Injection
* XML Bomb Denial-of-Service
* SOAP Injection
* Cross-Site Request Forgery (CSRF)
* Client Side Template Injection

## Set Up Instructions
Expand Down Expand Up @@ -69,7 +75,7 @@ Change directory to DVWS
cd dvws-node
```

npm install all dependencies (build from source is needed for `libxmljs`, you might also need install libxml depending on your OS: `sudo apt-get install -y libxml2 libxml2-dev`)
npm install all dependencies (build from source is needed for `libxmljs`, you might also need to install libxml depending on your OS: `sudo apt-get install -y libxml2 libxml2-dev`)


```
Expand Down Expand Up @@ -126,16 +132,11 @@ If the DVWS web service doesn't start because of delayed MongoDB or MySQL setup,


## To Do
* Cross-Site Request Forgery (CSRF)
* XML Bomb Denial-of-Service
* API Endpoint Brute Forcing

* Web Socket Security
* Type Confusion
* LDAP Injection
* SOAP Injection
* XML Injection
* GRAPHQL Denial Of Service
* CRLF Injection
* GraphQL Injection
* Webhook security

Expand Down
5 changes: 4 additions & 1 deletion app.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,12 @@ swaggerGen().then(() => {

app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(swaggerOutput));

app.listen(process.env.EXPRESS_JS_PORT, '0.0.0.0', () => {
const serverInstance = app.listen(process.env.EXPRESS_JS_PORT, '0.0.0.0', () => {
console.log(`🚀 API listening at http://dvws.local${process.env.EXPRESS_JS_PORT == 80 ? "" : ":" + process.env.EXPRESS_JS_PORT } (127.0.0.1)`);
});
}).catch(err => {
console.error("Unable to generate Swagger documentation", err);
process.exit(1);
});


Expand Down
58 changes: 57 additions & 1 deletion controllers/notebook.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken')
const { exec } = require('child_process');
var xpath = require('xpath');
const xml2js = require('xml2js');
const libxml = require('libxmljs');
const fs = require('fs');
dom = require('@xmldom/xmldom').DOMParser
const parser = new xml2js.Parser({ attrkey: "ATTR" });
Expand Down Expand Up @@ -200,6 +201,61 @@ module.exports = {
} finally {
await client.close();
}
},

// Vulnerability: XML Bomb / XXE (Import Notes)
import_notes_xml: async (req, res) => {
res = set_cors(req, res);

const xmlData = req.body.xml;
if (!xmlData) {
return res.status(400).send({ error: "XML data required" });
}

// Verify token
let result = {};
try {
const token = req.headers.authorization.split(' ')[1];
result = jwt.verify(token, process.env.JWT_SECRET, options);
} catch (e) {
return res.status(401).send({ error: "Unauthorized" });
}

const optionsXml = {
noent: true, // VULNERABLE: Enables entity substitution
dtdload: true,
huge: true // VULNERABLE: Bypasses parser limits (e.g. max node depth) to facilitate DoS
};

try {
const doc = libxml.parseXml(xmlData, optionsXml);

// Parse and save notes
const notes = doc.find('//note');
let count = 0;

for (const node of notes) {
const name = node.get('name') ? node.get('name').text() : ("Imported " + Date.now());
const body = node.get('body') ? node.get('body').text() : "";
const type = node.get('type') ? node.get('type').text() : "public";

const newNote = new Note({
name: name,
body: body,
type: type,
user: result.user
});
await newNote.save();
count++;
}

res.send({
success: true,
message: `Successfully imported ${count} notes.`,
parsedRoot: doc.root().name()
});
} catch (e) {
res.status(500).send(e);
}
}

}
19 changes: 19 additions & 0 deletions controllers/passphrase.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const jwt = require('jsonwebtoken');
var serialize = require("node-serialize")
const PDFDocument = require('pdfkit');
const fs = require('fs');
const bcrypt = require('bcrypt');
const User = require('../models/users');

const sequelize = require('../models/passphrase');

Expand Down Expand Up @@ -71,6 +73,23 @@ const options = {
let result = {};
const token = req.headers.authorization.split(' ')[1];
result = jwt.verify(token, process.env.JWT_SECRET, options);

// Verify credentials before export (Vulnerable: No Rate Limiting + User enumeration)
const { password, username } = req.body;
if (!password || !username) {
return res.status(400).send("Username and Password required");
}

try {
// Vulnerability: Uses username from body allowing brute force of any user
const user = await User.findOne({ username: username });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).send("Incorrect credentials");
}
} catch (err) {
return res.status(500).send(err.message);
}

const payload = Buffer.from(req.body.data, 'base64');
const data = serialize.unserialize(payload.toString());

Expand Down
199 changes: 198 additions & 1 deletion controllers/users.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const xml2js = require('xml2js');

const connUri = process.env.MONGO_LOCAL_CONN_URL;
const User = require('../models/users');

const options = {
expiresIn: '2d',
issuer: 'https://github.com/snoopysecurity',
algorithms: ["HS256", "none"],
ignoreExpiration: true
};

// In-memory log store for login attempts (Vulnerable to Log Pollution)
const loginLogs = [];

function set_cors(req,res) {
if (req.get('origin')) {
res.header('Access-Control-Allow-Origin', req.get('origin'))
Expand Down Expand Up @@ -97,6 +108,12 @@ module.exports = {
let result = {};
let status = 200;

// Vulnerability: Log Pollution via CRLF Injection
// We log the username directly without sanitization.
// If username contains \n, it creates a fake log entry on a new line.
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || "unknown";
loginLogs.push(`[${new Date().toISOString()}] Login attempt from IP:${ip} User:${username}`);

try {
const user = await User.findOne({username});
if (user) {
Expand Down Expand Up @@ -140,7 +157,8 @@ module.exports = {
result.error = `Authentication error`;
}
res.setHeader('Authorization', 'Bearer '+ result.token);
//res.cookie("SESSIONID", result.token, {httpOnly:true, secure:true});
// Set cookie for CSRF demonstration
res.setHeader('Set-Cookie', `auth_token=${result.token}; Path=/; HttpOnly`);
res.status(status).send(result);
} catch (err) {
status = 500;
Expand Down Expand Up @@ -178,5 +196,184 @@ module.exports = {
result.error = err;
}
res.status(status).send(result);
},

getLoginLogs: (req, res) => {
// Returns raw logs. Vulnerable to Log Pollution/Forgery if displayed line-by-line.
res.set('Content-Type', 'text/plain');
res.send(loginLogs.join('\n'));
},


// Vulnerability: XML Injection (Profile Export)
exportProfileXml: async (req, res) => {
// Scenario: User exports their profile to XML.
// Vulnerability: The 'bio' and 'username' fields are user-controlled and concatenated directly.
const username = req.body.username || "guest";
const bio = req.body.bio || "No bio";

// Construct XML manually (Vulnerable)
const xml = `
<userProfile>
<username>${username}</username>
<role>user</role>
<bio>${bio}</bio>
</userProfile>
`;

res.set('Content-Type', 'application/xml');
res.send(xml);
},

// Vulnerability: XML Injection (Profile Import - Mass Assignment)
importProfileXml: async (req, res) => {
// Scenario: User imports profile from XML.
// Vulnerability: The endpoint blindly accepts fields from the XML.
// Mass Assignment: If XML contains <admin>true</admin>, user becomes admin.

const xmlData = req.body.xml;
if (!xmlData) return res.status(400).send("XML required");

try {
const parser = new xml2js.Parser({ explicitArray: false });
const result = await parser.parseStringPromise(xmlData);

if (result && result.userProfile) {
const profile = result.userProfile;
const targetUser = profile.username;

// Build update object
const updateData = {};
if (profile.bio) updateData.bio = profile.bio;
// Vulnerability: Accepting admin flag from XML
if (profile.admin) updateData.admin = (profile.admin === 'true');

const updatedUser = await User.findOneAndUpdate(
{ username: targetUser },
updateData,
{ new: true }
);

if (!updatedUser) {
return res.status(404).send({ success: false, message: "Target user '" + targetUser + "' not found." });
}

res.send({
success: true,
message: "Profile updated successfully from XML.",
data: updatedUser
});
} else {
res.status(400).send("Invalid XML format. Root must be <userProfile>");
}
} catch (e) {
res.status(500).send("XML Import Error: " + e.message);
}
},

getProfile: async (req, res) => {
try {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET, options);

const user = await User.findOne({ username: decoded.user });
if (!user) return res.status(404).send("User not found");

res.send({
username: user.username,
bio: user.bio,
admin: user.admin
});
} catch (err) {
res.status(500).send(err.message);
}
},

adminCreateUser: async (req, res) => {
try {

let token;
if (req.headers.cookie) {
const cookies = req.headers.cookie.split(';');
const authCookie = cookies.find(c => c.trim().startsWith('auth_token='));
if (authCookie) token = authCookie.split('=')[1];
}

if (!token) return res.status(401).send({ error: "Unauthorized" });

// Verify token is Admin
const decoded = jwt.verify(token, process.env.JWT_SECRET, options);
const user = await User.findOne({ username: decoded.user });
if (!user || !user.admin) return res.status(403).send({ error: "Forbidden: Admin only" });

// 2. Parse Body (Parses JSON even if Content-Type is text/plain)
let data = req.body;
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) { /* ignore */ }
}

// 3. Create User
if (data && data.username && data.password) {
const existing = await User.findOne({ username: data.username });
if (existing) return res.status(409).send({ error: "User already exists" });

const newUser = new User({
username: data.username,
password: data.password,
admin: !!data.admin
});
await newUser.save();
res.status(200).send({ message: `User ${data.username} created successfully.` });
} else {
res.status(400).send({ error: "Missing username or password" });
}
} catch (err) {
res.status(500).send({ error: err.message });
}
},



// Vulnerability: LDAP Injection
ldapSearch: (req, res) => {
const user = req.query.user || req.body.user;

// Vulnerability: Unsanitized input concatenated into LDAP filter
// Standard filter: (uid=username)
const filter = "(uid=" + user + ")";

// Simulated LDAP Server Logic
let results = [];

// 1. Wildcard Injection: user = "*"
if (user === "*" || filter.includes("(uid=*)")) {
results = ["admin", "guest", "manager"];
}
// 2. Attribute Injection: user = "admin)(objectClass=*)"
// Filter becomes: (uid=admin)(objectClass=*)
else if (filter.includes(")(objectClass=*)")) {
// Vulnerability Impact: By injecting a valid second filter, the attacker might bypass field restrictions
// or trigger a verbose mode, revealing sensitive attributes normally hidden.
results = [
{
username: "admin",
email: "admin@internal.dvws",
guid: "a1b2-c3d4-e5f6",
description: "Super User with unrestricted access",
password: "letmein"
}
];
}
// Normal match
else if (user === "admin") {
results = ["admin"];
}

res.status(200).send({
filter: filter, // Reflect filter for educational/debugging
results: results
});
}
};
5 changes: 5 additions & 0 deletions models/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const userSchema = new Schema({
admin: {
type: Boolean,
default: false
},
bio: {
type: 'String',
required: false,
default: "No bio yet."
}
});

Expand Down
Loading