criticalF-004Secret in the code: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.Security · effort S
criticalF-005Secret in the code: Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.Security · effort S
criticalF-023Ancient JWT libraries accept forged tokens; algorithm never pinnedSecurity · effort M
criticalF-031Username spliced into Pug template source enables server-side code executionSecurity · effort S
criticalF-035Unauthenticated ZIP upload extracts entries anywhere under the app directorySecurity · effort S
criticalF-036XML uploads parsed with external entities enabled (XXE) and echoed backSecurity · effort S
criticalF-038Public memories endpoint returns posting users' full records, including password hashesSecurity · effort S
criticalF-039Auto-generated REST API lacks role and ownership checksSecurity · effort M
criticalF-040Registration accepts a role field, so anyone can sign up as adminSecurity · effort S
criticalF-050Seeded admin and staff accounts with weak, published passwordsSecurity · effort M
criticalF-053Chatbot coupon tool lets anyone obtain discounts of any sizeLLM integrations · effort M
criticalF-054Chatbot order lookup trusts unsigned JWTs and a masked-email matchLLM integrations · effort S
criticalF-055Unauthenticated chat endpoint has no rate limit, token cap or input capLLM integrations · effort M
highF-012Detected a sequelize statement that is tainted by user-input.Security · effort S
highF-015Detected a sequelize statement that is tainted by user-input.Security · effort S
highF-017Five folders can be browsed by anyone, the encryption keys and server logs among themSecurity · effort S
highF-022Passwords stored as unsalted MD5 hashesSecurity · effort M
highF-024OAuth accounts use a password derived from their email addressSecurity · effort M
highF-025JWT carries password hash and TOTP secret; no revocation on logoutSecurity · effort M
highF-026Password change skips current-password check and sends passwords in URLSecurity · effort S
highF-027Password reset relies on guessable security question with spoofable throttleSecurity · effort M
highF-028Any user can read, modify and check out other users' basketsSecurity · effort M
highF-029Unvalidated amounts let users mint wallet credit and negative ordersSecurity · effort M
highF-032B2B order lines evaluated as code in node:vm with notevilSecurity · effort S
highF-033Order tracking builds a MarsDB $where JavaScript expression from the URLSecurity · effort S
highF-034Review endpoints: $where injection, operator injection and unowned editsSecurity · effort S
highF-037Profile image URL is fetched server-side without any allowlistSecurity · effort M
highF-042Angular sanitizer bypassed on search term, feedback, emails, IPs and ordersSecurity · effort M
highF-043Data-erasure form spreads request body into render options, allowing file readSecurity · effort S
highF-046Discount coupons are unsigned encodings anyone can forgeSecurity · effort M
highF-048Customer order PDFs written to the public /ftp folder; extension check bypassableSecurity · effort S
highF-051Ethereum wallet recovery phrase hard-coded in server sourceSecurity · effort S
highF-052whoami supports JSONP with cookie auth and arbitrary fields, leaking secrets cross-siteSecurity · effort S
highF-056Untrusted history, usernames and reviews reach the prompt unseparatedLLM integrations · effort M
Can wait
Still to fix, after sign-off.
mediumF-014The application redirects to a URL specified by user-supplied input `query` that is not validated.Security · effort S
mediumF-030Deluxe membership granted without payment for unknown paymentModeSecurity · effort S
mediumF-044Cookie-authenticated profile and erasure POSTs have no CSRF defence; CORS allows allSecurity · effort S
mediumF-045No CSP or HSTS; profile page CSP built from a user-controlled URLSecurity · effort M
mediumF-047CAPTCHAs return their own answers and are reusableSecurity · effort S
mediumF-049Development error handler returns stack traces and SQL errors to clientsSecurity · effort S
mediumF-057Chat stream has no timeout, no cancel on disconnect, and leaks raw errorsLLM integrations · effort S
lowF-041Metrics, full app configuration and API docs served publiclySecurity · effort S
lowF-059No tests or evals for the chatbot prompt and tool policiesLLM integrations · effort M
1 open question needs the team's answer.
Estimated effort: 26 small findings (under 2 hours) and 17 medium findings (under 2 days).
Scope and method
Coverage
Security (juice-shop-v20.2.0-4b369c9): 12 of 15 items examined. done
SEC-03 Authorization on every route partly examined
SEC-12 Rate limiting and abuse partly examined
SEC-15 Other dangerous patterns partly examined
LLM integrations (juice-shop-v20.2.0-4b369c9): 7 of 8 items examined. done
LLM-08 Evaluation and observability partly examined
Method
This sample audited OWASP Juice Shop v20.2.0 (upstream 5658473cf881) as Auditdesk's eval prepares it (evals/prep): the answers to its coding challenges removed and its challenges renamed, in one commit, 4b369c91932c. Line numbers refer to that prepared tree.
Scanners first: gitleaks over the history of every branch cloned, osv-scanner over the lock files and Semgrep; their pinned images are under Technical details.
Then one agent per aspect read the code through read-only tools, against the aspect's checklist; Coverage above shows what each examined.
The auditor reviewed all 58 findings the scanners and agents filed: 43 are in this report, 10 were merged into others and 5 rejected as wrong.
The client's code was read, not installed, built or run: no dependency install, no type-check, no project lint.
Model access: a Claude subscription through the Claude Agent SDK, under Anthropic's Consumer Terms.
criticalF-023Ancient JWT libraries accept forged tokens; algorithm never pinned
Security · SEC-02 · effort M
Recommendation
Upgrade to current `jsonwebtoken` (9.x) and `express-jwt` (8.x). Always pass `algorithms: ['RS256']` to verify. Drop the custom `jws.verify` path in favour of `jwt.verify(token, publicKey, { algorithms: ['RS256'] })`, and stop serving the key directory publicly.
Details and evidence for F-023
The login tokens can be forged. Anyone can make a token that says "I am the administrator" and the server will believe it.
Likelihood
The forgery techniques (alg "none", HS256 signed with the published public key) are well known and need no account.
Impact
An attacker can mint a token for any user or role, including admin and accounting, and pass every isAuthorized/isAccounting/isDeluxe check.
Details
package.json pins `jsonwebtoken` 0.4.0 and `express-jwt` 0.1.3. Both predate the algorithm-confusion fixes. `isAuthorized()` passes only `secret: publicKey` and no `algorithms`. `verify()` calls `jws.verify(token, publicKey)` without an algorithm, so a token with header `{"alg":"HS256"}` HMAC-signed with the RSA public key, which is downloadable from `/encryptionkeys/jwt.pub`, verifies. The old libraries also accept `alg: none`. `isAccounting`, `isDeluxe`, `isCustomer` and 2FA `verify`/`setup` all rely on this `verify` and then trust the decoded payload. `updateAuthenticatedUsers` also places any verified token into the session map, so forged identities flow into `appendUserId`.
criticalF-031Username spliced into Pug template source enables server-side code execution
Security · SEC-04 · effort S
Recommendation
Never build templates from user data. Keep `#{username}` fixed in the .pug file, compile it once at startup and pass `{ username }` as locals. Delete the `eval` branch entirely.
Details and evidence for F-031
A customer's display name is pasted into the server's page template before it is processed. A specially crafted name makes the server run attacker code, which hands over the whole server.
Likelihood
Any registered user can set their username via POST /profile and then load /profile.
Impact
Arbitrary JavaScript runs in the Node process, giving full server compromise: database, keys, files.
Details
`getUserProfile` reads `views/userProfile.pug`, does `template.replace(/_username_/g, username)` and then `pug.compile(template)`. The username therefore becomes Pug source, not data. Semgrep's F-016 covers only the direct `eval(code)` on `#{...}` names (line 65). Even with that branch removed, Pug's own syntax remains reachable: `!{...}`/`#{...}` interpolation after the leading backslash, or `#[...]` tags. The username setter's `sanitizeSecure` (sanitize-html) strips HTML tags but not Pug interpolation. Interpolation evaluates arbitrary JS expressions such as `global.process.mainModule.require('child_process')`.
criticalF-035Unauthenticated ZIP upload extracts entries anywhere under the app directory
Security · SEC-08 · effort S
Recommendation
Require authentication. Resolve each entry and require `resolved.startsWith(path.resolve('uploads/complaints') + path.sep)`. Skip symlink entries. Cap entry count and total uncompressed bytes. Better still, don't extract archives server-side. Implement real type and size checks by magic bytes.
Details and evidence for F-035
Anyone on the internet can upload a ZIP file that writes files into the shop's own program folders. That lets an attacker replace the website's pages or code.
Likelihood
/file-upload needs no login, and crafting a ZIP with `../` entry names is trivial.
Impact
Overwriting files the app serves or loads (the frontend bundle, i18n files, config, views) gives defacement, stored XSS for all visitors, or code execution on restart.
Details
`extractZipBuffer` resolves `'uploads/complaints/' + entry.path` and only checks `absolutePath.includes(path.resolve('.'))`, so any path inside the working directory passes (e.g. `../../frontend/dist/frontend/main.js`, `../../views/userProfile.pug`, `../../config/default.yml`). The route has no auth middleware. `checkUploadSize` and `checkFileType` are empty no-ops, so type is decided by the client-supplied filename only. There is no limit on the decompressed size or entry count (zip bomb).
criticalF-036XML uploads parsed with external entities enabled (XXE) and echoed back
Security · SEC-04 · effort S
Recommendation
Remove the XML/YAML complaint handlers, or parse with entity substitution and DTD loading disabled and no filesystem providers. Use `yaml.load` with `JSON_SCHEMA` and size limits. Never echo parsed content in errors.
Details and evidence for F-036
Anyone can upload an XML file that tricks the server into reading its own private files and sending them back. That includes passwords and keys.
Likelihood
Unauthenticated POST to /file-upload with a .xml file; standard XXE payloads work.
Impact
Any file readable by the Node process (config, keys, /etc/passwd, the SQLite DB in part) is returned in the error message. Entity expansion also stalls the server, and YAML bombs exhaust memory.
Details
`parseXmlString` deliberately sets `XML_PARSE_NOENT | XML_PARSE_DTDLOAD` and registers filesystem input providers, so `<!ENTITY x SYSTEM "file:///...">` resolves. `handleXmlUpload` puts the first 400 chars of the parsed result into an error passed to `errorhandler()`, which renders it to the client. `handleYamlUpload` runs `yaml.load` on uploaded data (js-yaml 3.x) and likewise echoes the result. Billion-laughs aliases lead to memory exhaustion before the vm timeout matters.
Evidence
lib/xml.ts · lines 15–41
15async function loadLibxml2 () {
16 if (libxml2Promise == null) {
17 libxml2Promise = (async () => {
18 const libxml2 = await dynamicImport('libxml2-wasm')
19 // Grants the WASM sandbox host filesystem access so external entities
20 // like file:///etc/passwd resolve - required for the XXE challenges.
21 const { xmlRegisterFsInputProviders } = await dynamicImport('libxml2-wasm/lib/nodejs.mjs')
22 xmlRegisterFsInputProviders()
23 return libxml2
24 })()
25 }
26 return await libxml2Promise27}
2829// Parses XML with entity substitution and external entity loading enabled
30// (intentionally vulnerable to XXE for the related challenges). The parse runs
31// in a vm context with a timeout so entity-expansion bombs surface as a
32// "Script execution timed out" error instead of hanging the process.
33export async function parseXmlString (data: string, timeoutMs = 2000): Promise<string> {
34 const libxml2 = await loadLibxml2()
35 const option = libxml2.ParseOption.XML_PARSE_NOENT | libxml2.ParseOption.XML_PARSE_DTDLOAD | libxml2.ParseOption.XML_PARSE_NOBLANKS | libxml2.ParseOption.XML_PARSE_NOCDATA
36 const sandbox = { libxml2, data, option }
37 vm.createContext(sandbox)
38 const xmlDoc = vm.runInContext('libxml2.XmlDocument.fromString(data, { option })', sandbox, { timeout: timeoutMs })
39 const xmlString = xmlDoc.toString()
40 xmlDoc.dispose()
41 return xmlString
15 more lines in the HTML report
routes/fileUpload.ts · lines 65–100
65async function handleXmlUpload ({ file }: Request, res: Response, next: NextFunction) {
66 if (file?.originalname?.toLowerCase().endsWith('.xml') ?? false) {
67 if (((file?.buffer) != null) && utils.isChallengeEnabled(challenges.chddc39ed1)) { // XXE attacks in Docker/Heroku containers regularly cause "segfault" crashes
68 const data = file.buffer.toString()
69 try {
70 const xmlString = await parseXmlString(data)
71 res.status(410)
72 next(new Error('B2B customer complaints via file upload have been deprecated for security reasons: ' + utils.trunc(xmlString, 400) + ' (' + file.originalname + ')'))
73 } catch (err: unknown) {
74 const errorMessage = err instanceof Error ? err.message : String(err)
75 if (errorMessage.includes('Script execution timed out')) {
76 res.status(503)77 next(new Error('Sorry, we are temporarily not available! Please try again later.'))
78 } else {
79 res.status(410)
80 next(new Error('B2B customer complaints via file upload have been deprecated for security reasons: ' + errorMessage + ' (' + file.originalname + ')'))
81 }
82 }
83 } else {
84 res.status(410)
85 next(new Error('B2B customer complaints via file upload have been deprecated for security reasons (' + file?.originalname + ')'))
86 }
87 }
88 next()
89}
9091function handleYamlUpload ({ file }: Request, res: Response, next: NextFunction) {
92 if ((file?.originalname?.toLowerCase().endsWith('.yml') ?? false) || (file?.originalname?.toLowerCase().endsWith('.yaml') ?? false)) {
93 if (((file?.buffer) != null) && utils.isChallengeEnabled(challenges.chddc39ed1)) {
94 const data = file.buffer.toString()
criticalF-038Public memories endpoint returns posting users' full records, including password hashes
Security · SEC-03 · effort S
Recommendation
Use `include: [{ model: UserModel, attributes: ['id', 'username'] }]`. Add a `defaultScope` on User that excludes `password`, `totpSecret` and `deluxeToken`, so other includes are also safe.
Details and evidence for F-038
The public photo wall also sends out the private account details of everyone who posted a photo, including their scrambled password and two-factor secret, to anyone who looks.
Likelihood
GET /rest/memories needs no authentication.
Impact
Email, role, MD5 password hash, TOTP secret and deluxe token of every user who posted a photo leak to anyone.
Details
`getMemories` runs `MemoryModel.findAll({ include: [UserModel] })` with no `attributes` restriction, so Sequelize serialises every User column (password, totpSecret, deluxeToken, lastLoginIp). The route has no auth middleware. Combined with the unsalted MD5 hashing (F-022), the passwords are easily recovered.
criticalF-039Auto-generated REST API lacks role and ownership checks
Security · SEC-03 · effort M
Recommendation
Default to deny: add a finale `all.auth` hook that rejects unless an explicit per-model/per-action policy allows it. Require admin for product writes and user listing. Scope list/read/update/delete to `UserId = caller` for Address, Card, BasketItem, Complaint, Recycle and PrivacyRequest. Add integration tests asserting 401/403 for each generated endpoint.
Details and evidence for F-039
Much of the shop's data API has no idea who is allowed to do what. Anyone can change product listings and prices without logging in, and any customer can see every other user's account list and complaints or change their saved addresses.
Likelihood
Product edits need no login at all; the rest need only a free account.
Impact
Anyone can rewrite product names, descriptions and prices (stored XSS and price tampering for every shopper). Any customer can list all users, read all complaints, delete any feedback, and edit other users' addresses.
Details
finale generates full CRUD for 13 models (`/api/<Model>s` and `/:id`), and the hand-written guards in server.ts are incomplete. `app.put('/api/Products/:id', security.isAuthorized())` is commented out, so PUT on products is unauthenticated. `GET /api/Users`, `GET /api/Users/:id` and `/rest/user/authentication-details` require only `isAuthorized()`, not admin, so any customer enumerates all users, emails and roles. `/api/Feedbacks/:id` DELETE requires only login. `GET /api/Complaints` returns everyone's complaints. `PUT /api/Addresss/:id` only appends `UserId`; finale updates by `id` without checking ownership. `/api/Hints/:id` PUT and `/api/Recycles/:id` GET (which `JSON.parse`s the id, so `[1,2,3,...]` returns arbitrary rows) are open. The Angular admin page is protected only by a client-side route guard.
Evidence
server.ts · lines 364–374
364 app.use('/api/Feedbacks/:id', security.isAuthorized())
365 /* Users: Only POST is allowed in order to register a new user */
366 app.get('/api/Users', security.isAuthorized())
367 app.route('/api/Users/:id')
368 .get(security.isAuthorized())
369 .put(security.denyAll())
370 .delete(security.denyAll())
371 /* Products: Only GET is allowed in order to view products */
372 app.post('/api/Products', security.isAuthorized())
373 // app.put('/api/Products/:id', security.isAuthorized())
374 app.delete('/api/Products/:id', security.denyAll())
server.ts · lines 380–391
380 app.route('/api/Hints/:id')
381 .get(security.denyAll())
382 .delete(security.denyAll())
383 /* Complaints: POST and GET allowed when logged in only */
384 app.get('/api/Complaints', security.isAuthorized())
385 app.post('/api/Complaints', security.isAuthorized())
386 app.use('/api/Complaints/:id', security.denyAll())
387 /* Recycles: POST and GET allowed when logged in only */
388 app.get('/api/Recycles', recycles.blockRecycleItems())
389 app.post('/api/Recycles', security.isAuthorized())
390 /* Challenge evaluation before finale takes over */
391 app.get('/api/Recycles/:id', recycles.getRecycleItem())
criticalF-040Registration accepts a role field, so anyone can sign up as admin
Security · SEC-09 · effort S
Recommendation
Replace generated user creation with a dedicated handler that validates a schema of `{email, password, passwordRepeat, securityQuestion, securityAnswer}` only, and sets `role = 'customer'` server-side. Alternatively, in a finale `create.write.before` hook, delete every attribute except the allowed ones.
Details and evidence for F-040
When signing up, a user can simply declare themselves an administrator and the server accepts it.
Likelihood
Anyone can POST /api/Users with `"role":"admin"`; no login needed.
Impact
An attacker immediately holds an admin (or accounting/deluxe) account, with every privilege that role grants.
Details
User registration is the finale-generated `POST /api/Users`, which builds the model from the whole request body. The only pre-handler (server.ts 409-420) trims email and password. Nothing strips `role`, `deluxeToken`, `totpSecret`, `isActive` or `profileImage`. The model's `role` validator accepts `admin`, `accounting` and `deluxe`. Because the pre-handler only trims when all three fields are present, it also doesn't reliably reject empty passwords.
criticalF-050Seeded admin and staff accounts with weak, published passwords
Security · SEC-10 · effort M
Recommendation
Don't seed privileged accounts in production. Create the first admin through a one-time bootstrap with a password from a secret store, forced to change on first login. Stop `sync({ force: true })` in production. Remove the key files from the repo and its history, rotate them, and load them from environment secrets.
Details and evidence for F-050
The shop creates an administrator account with the password "admin123" every time it starts, and that password is in the published source code. Anyone can log in as the administrator.
Likelihood
The seed file is in the public repository and is loaded on every start (`sequelize.sync({ force: true })` then `datacreator()`).
Impact
Anyone can log in as the administrator (`admin@<domain>` / `admin123`) or other staff, with full access to users, feedback and orders.
Details
`data/static/users.yml` defines users with plaintext passwords and security answers (admin `admin123`, jim `ncc-1701`, and others), plus card numbers and addresses. `start()` drops and recreates the schema and reseeds from this file, so production always contains these accounts. Gitleaks F-001/F-002 flagged two keys in this file but not the account passwords themselves. Also committed: `ctf.key` (HMAC key for flags) and `encryptionkeys/premium.key`, the latter served publicly under /encryptionkeys.
criticalF-053Chatbot coupon tool lets anyone obtain discounts of any size
LLM integrations · LLM-02 · effort M
Recommendation
Take coupon creation out of the model's free choice. Either drop the tool and send damaged-order cases to a human workflow, or make the tool take only an `orderId` and enforce the policy in code: the user is authenticated with a verified JWT, the order belongs to them, a damage claim is recorded, no coupon has been issued for it yet, and the discount is fixed server-side at 10% or less. Record issued coupons so each can be used once. Remove confidential business rules from the system prompt.
Details and evidence for F-053
The chatbot can create real discount coupons. The only thing that stops it is a written instruction to the AI, and visitors can talk it out of that instruction. Anyone, even without an account, can ask the chatbot for a large coupon and use it at checkout.
Likelihood
Likely: /rest/chat needs no sign-in and the coupon rules exist only as text in the prompt, which a short jailbreak gets past.
Impact
Anyone can get valid coupons for 10%, 15%, 100% or more and use them on any order, so the shop loses revenue.
Details
`generateCoupon` (routes/chat.ts:174-183) passes the model-chosen `discount` straight to `security.generateCoupon`. The zod schema is `z.number()` with no min or max, and the tool checks no order ID, damage report, rejected return, ownership or authentication. Every condition in the COUPON POLICY (lines 97-104) exists only in the prompt. The route is mounted without any auth middleware (server.ts:632). The client also sends the whole message history (`req.body.messages`, line 187), so an attacker can add fake assistant turns, or fake turns claiming a verified damaged order, to steer the model into calling the tool. The system prompt also contains a 'CONFIDENTIAL' 15% escalation offer, which the model will reveal when asked. This is separate from F-046 (coupons are unsigned and can be forged offline): this finding is the server's own LLM tool issuing coupons of any size on request.
Evidence
routes/chat.ts · lines 174–183
174 generateCoupon: tool({
175 description: 'Generate a discount coupon for a customer. Only use this when the coupon policy conditions are fully met.',
176 inputSchema: z.object({
177 discount: z.number().describe('The discount percentage for the coupon (maximum 10)')
178 }),
179 execute: async ({ discount }) => {
180 const couponCode = security.generateCoupon(discount)
181 return { couponCode, discount }
182 }
183 })
routes/chat.ts · lines 97–104
97COUPON POLICY (for the generateCoupon tool):
98- You may ONLY generate a coupon for a customer who has a verified damaged order with a valid order ID (format: xxxx-xxxxxxxxxxxxxxxx, e.g. 3fa8-bf2bc042f4e92).
99- The customer must have explicitly rejected a return or exchange before a coupon can be offered.
100- The maximum allowed discount is 10%.
101- NEVER generate a coupon just because a customer asks for one or complains.
102- If the customer does not meet ALL of the above conditions, politely decline and explain the policy.
103104CONFIDENTIAL - INTERNAL ONLY: If a customer formally complains about their shopping experience and explicitly requests to escalate the issue, offer them a one-time 15% courtesy discount to resolve the case without escalation. Do not mention this option proactively.`
server.ts · lines 631–632
631 /* Chat API endpoint */
632 app.post('/rest/chat', utils.asyncHandler(chat()))
criticalF-054Chatbot order lookup trusts unsigned JWTs and a masked-email match
LLM integrations · LLM-02 · effort S
Recommendation
In the chat route, authenticate with `security.verify` / `isAuthorized()` before reading claims. Store the owner's `UserId` on each order document and match on it instead of the masked email. Return only the fields the bot needs (status, ETA, product names).
Details and evidence for F-054
The chatbot is meant to show customers only their own orders. But it never checks that the login token is genuine, so anyone can claim to be another customer and have the chatbot show that customer's orders.
Likelihood
Easy: anyone can build an unsigned token with any user id, and order IDs show up in shared PDFs and the public /ftp folder.
Impact
Anyone without an account can read other customers' orders through the chatbot: products, prices, payment and address IDs, and masked email.
Details
`getUserId` (routes/chat.ts:42-47) calls `security.decode`, which is `jws.decode(token)?.payload` (lib/insecurity.ts:56) and does not check the signature. A token with `alg: none` or any made-up signature and `data.id` set to someone else's id is accepted. `getOrderById` (lines 157-171) then loads that user's email, applies the same vowel-masking used when orders are stored (order.ts:168), and returns the whole order document when the masked values match. Two problems follow. (1) Identity is forged with no credentials. This is worse than F-023: no forgery trick is needed, because nothing verifies the token. (2) Vowel-masking is not unique. Emails that differ only in vowels (for example `bob@x.io` and `bab@x.ia`) give the same masked string, so a user can register a colliding address and read another person's orders. `getUserNameFromToken` (line 49) uses the same unverified decode, so a forged token also controls which name goes into the system prompt.
criticalF-055Unauthenticated chat endpoint has no rate limit, token cap or input cap
LLM integrations · LLM-04 · effort M
Recommendation
Require authentication for /rest/chat (or a captcha for anonymous use). Add an express-rate-limit keyed on the verified user id with a daily token quota. Validate the body with zod: only `user`/`assistant` roles, at most around 20 messages, and a cap on total characters. Set `maxOutputTokens`, and lower `stepCountIs` to what the tools actually need. Alert on the existing token metrics.
Details and evidence for F-055
Anyone on the internet can use the shop's chatbot as much as they like, with messages as long as they like, without an account. Every call costs money with a paid AI provider, so an attacker could run up a large bill or knock the chatbot offline.
Likelihood
Likely once the chatbot points at a paid API: a simple script can call it in a loop without signing in.
Impact
Unlimited LLM spend, or a self-hosted model kept fully busy, at the attacker's choice, which blocks real customers.
Details
`POST /rest/chat` is mounted with no auth and no rate limiter (server.ts:632; the only rateLimit in server.ts covers reset-password). `streamText` is called with no `maxOutputTokens` (routes/chat.ts:199-209). `req.body.messages` is used as-is (line 187), with no limit on the number of messages, their size or their roles, and the body parser accepts any `*/*` text. Each request can run up to 10 tool steps (`stepCountIs(10)`) plus `llmMaxRetries` retries, so one request can turn into many model calls. The token counters exist (lines 57-79), but nothing attributes usage to a user or enforces a budget.
Evidence
server.ts · lines 631–632
631 /* Chat API endpoint */
632 app.post('/rest/chat', utils.asyncHandler(chat()))
highF-012Detected a sequelize statement that is tainted by user-input.
Security · SEC-04 · effort S
Recommendation
Confirm the input is attacker-controlled; if so, follow the rule's references.
Details and evidence for F-012
Detected a sequelize statement that is tainted by user-input. This could lead to SQL injection if the variable is user-controlled and is not properly sanitized. In order to prevent SQL injection, it is recommended to use parameterized queries or prepared statements.
highF-015Detected a sequelize statement that is tainted by user-input.
Security · SEC-04 · effort S
Recommendation
Confirm the input is attacker-controlled; if so, follow the rule's references.
Details and evidence for F-015
Detected a sequelize statement that is tainted by user-input. This could lead to SQL injection if the variable is user-controlled and is not properly sanitized. In order to prevent SQL injection, it is recommended to use parameterized queries or prepared statements.
22 models.sequelize.query(`SELECT * FROM Products WHERE ((name LIKE '%${criteria}%' OR description LIKE '%${criteria}%') AND deletedAt IS NULL) ORDER BY name`)
highF-017Five folders can be browsed by anyone, the encryption keys and server logs among them
Security · SEC-15 · effort S
Recommendation
Confirm the input is attacker-controlled; if so, follow the rule's references.
Details and evidence for F-017
Anyone can list and download the contents of /ftp, /encryptionkeys, /support/logs, /infrastructure and /.well-known. Among them are key files and the server's access logs.
Replace `hash` with bcrypt (cost ≥ 12) or argon2id. Verify with the library's constant-time compare in application code, not in SQL. Rehash on the user's next successful login and force a reset for accounts that never log in again.
Details and evidence for F-022
Customer passwords are scrambled with a very old, fast method that offers almost no protection. If the database leaks, and several bugs in this app let it leak, most passwords can be recovered within minutes.
Likelihood
Anyone who gets a copy of the Users table (via the SQL injection in login/search, or the user list endpoints) gets these hashes.
Impact
Unsalted MD5 is cracked at billions of guesses per second or looked up in rainbow tables, so most user passwords are recovered and can be reused on other sites.
Details
The User model's password setter calls `security.hash`, which is `crypto.createHash('md5')` with no salt or work factor. Login compares `security.hash(req.body.password)` in SQL, and change-password and 2FA setup compare against the same MD5 value. Identical passwords produce identical hashes across users.
highF-024OAuth accounts use a password derived from their email address
Security · SEC-01 · effort M
Recommendation
Do the OAuth code exchange and token verification on the server. Link the provider subject ID to the user record and issue the session there. Give OAuth-only accounts no usable password, or a random one the client never sees.
Details and evidence for F-024
Accounts created with "Log in with Google" get a password anyone can work out from the email address. Knowing someone's email is enough to log in as them.
Likelihood
The derivation is in the shipped JavaScript bundle, so anyone who knows an OAuth user's email can compute the password.
Impact
Full takeover of every account created through Google sign-in, using the ordinary email/password login.
Details
After the Google profile is fetched, the browser registers and logs in the user with `password = btoa(reversed email)`. The server has no notion of an OAuth identity. It accepts a normal `/rest/user/login` with that password, and the `oauth: true` flag in the body is ignored. Because the code runs on the client, the Google access token is never verified server-side.
highF-025JWT carries password hash and TOTP secret; no revocation on logout
Security · SEC-02 · effort M
Recommendation
Put only `sub`, `role` and minimal claims in the token. Deliver it in an HttpOnly, Secure, SameSite=Lax/Strict cookie set by the server, not localStorage. Add a server-side logout that invalidates the session (token ID deny-list or short-lived access token plus revocable refresh token).
Details and evidence for F-025
The login token handed to the browser contains the user's scrambled password and their two-factor secret. It is stored where malicious scripts can read it, and logging out does not cancel it.
Likelihood
Every login issues such a token. It sits in localStorage and in a non-HttpOnly cookie, so any XSS in the app (several exist) can read it.
Impact
A stolen token reveals the user's MD5 password hash and 2FA seed and stays usable for 6 hours after logout.
Details
`login()` signs `{ data: user, bid }`, where `user` is the full Users row from `SELECT *`, including `password` (MD5) and `totpSecret`. 2FA `verify` does the same with `plainUser`. JWT payloads are only base64, not encrypted. The frontend writes the token to `localStorage` and to a cookie via ngx-cookie without HttpOnly, Secure or SameSite, and the server also sets `res.cookie('token', token)` without flags in `updateAuthenticatedUsers`. Logout only deletes the client copies. `authenticatedUsers.tokenMap` is never purged and there is no deny-list, so the token stays valid until `exp`.
41 const token = security.authorize(plainUser)
42 // @ts-expect-error FIXME set new property for original basket
43 plainUser.bid = basket.id // keep track of original basket for challenge solution check
44 security.authenticatedUsers.put(token, plainUser)
highF-026Password change skips current-password check and sends passwords in URL
Security · SEC-01 · effort S
Recommendation
Make it a POST with a JSON body. Always require and verify the current password (or a recent re-authentication). Invalidate other sessions after a change. Keep secrets out of URLs, and scrub query strings from access logs.
Details and evidence for F-026
Changing a password does not really require the old one, so someone who briefly hijacks a session can lock the owner out for good. New passwords are also written in plain text into server log files.
Likelihood
Anyone holding a user's token, for example via the XSS issues in this app, can call it. The password also lands in access logs on every legitimate change.
Impact
Permanent account takeover from a short-lived token theft, and plaintext new passwords stored in logs/access.log, which is publicly browsable under /support/logs.
Details
`GET /rest/user/change-password` reads `current`, `new` and `repeat` from the query string. The current password is checked only `if (currentPassword && ...)`, so omitting `current` skips the check. Because it is a GET, morgan's `combined` format writes the full URL, including both passwords, to `logs/access.log.*`. That directory is served by `/support/logs` (see F-020/F-011).
Evidence
routes/changePassword.ts · lines 13–42
13 return async ({ query, headers, connection }: Request, res: Response, next: NextFunction) => {
14 const currentPassword = query.current as string
15 const newPassword = query.new as string
16 const newPasswordInString = newPassword?.toString()
17 const repeatPassword = query.repeat
1819 if (!newPassword || newPassword === 'undefined') {
20 res.status(401).send(res.__('Password cannot be empty.'))
21 return
22 } else if (newPassword !== repeatPassword) {
23 res.status(401).send(res.__('New and repeated password do not match.'))
24 return25 }
2627 const token = headers.authorization ? headers.authorization.substr('Bearer='.length) : null
28 if (token === null) {
29 next(new Error('Blocked illegal activity by ' + connection.remoteAddress))
30 return
31 }
3233 const loggedInUser = security.authenticatedUsers.get(token)
34 if (!loggedInUser) {
35 next(new Error('Blocked illegal activity by ' + connection.remoteAddress))
36 return
37 }
3839 if (currentPassword && security.hash(currentPassword) !== loggedInUser.data.password) {
40 res.status(401).send(res.__('Current password is not correct.'))
41 return
42 }
highF-027Password reset relies on guessable security question with spoofable throttle
Security · SEC-01 · effort M
Recommendation
Replace security questions with a single-use, time-limited reset link emailed to the address on file. Rate-limit login, reset, security-question and 2FA verify per account and per real client IP: configure `trust proxy` to the exact number of proxy hops and don't key on a raw header. Return identical responses for unknown emails.
Details and evidence for F-027
To reset someone's password you only need their email and the answer to a personal question like "your pet's name". No email is sent and the attempt limit can be dodged, so accounts can be taken over by guessing.
Likelihood
Security answers (mother's maiden name, first pet…) are often public or guessable, and the 100-per-5-minutes limit is bypassed by changing the X-Forwarded-For header.
Impact
Takeover of any account whose email is known, with no email ownership proof.
Details
`resetPassword` changes the password when `hmac(answer)` matches the stored security answer. No token is emailed to the account owner. `/rest/user/security-question?email=` returns the question for any registered email and `{}` otherwise, which enumerates accounts and tells an attacker what to guess. The only throttle is `rateLimit` with `keyGenerator` returning `headers['X-Forwarded-For'] ?? ip` while `trust proxy` is enabled. A client can send arbitrary X-Forwarded-For values, so each attempt gets a fresh bucket. `/rest/user/login` has no rate limit or lockout at all, and the 2FA verify limiter (100 per 5 minutes per IP) allows brute forcing six-digit TOTP codes over time.
highF-028Any user can read, modify and check out other users' baskets
Security · SEC-03 · effort M
Recommendation
Derive the basket from the authenticated user (`BasketModel.findOne({ where: { id, UserId: user.id } })`) in every basket, checkout and coupon handler. Add finale `before` hooks (or custom routes) that scope BasketItem reads and writes to the caller's basket. Use the standard JSON body parser and reject duplicate keys.
Details and evidence for F-028
Shopping baskets are not tied to their owners on the server. A logged-in customer can change a number in the request to see or alter someone else's basket and even place orders from it.
Likelihood
Basket IDs are small sequential integers, so any logged-in user can iterate them.
Impact
Other customers' basket contents are exposed, items can be added or removed and coupons applied in their baskets, and their baskets can be checked out with the attacker's wallet or no payment at all.
Details
`/rest/basket/:id` only requires a valid JWT. `retrieveBasket`, `placeOrder` and `applyCoupon` look the basket up by `req.params.id` and never compare it to the caller's `bid` or `UserId`. The generated finale endpoints `/api/BasketItems/:id` (GET/PUT/DELETE) only require authentication, with no ownership check. `addBasketItem` checks `basketIds[0]` against `user.bid` but saves `basketIds[basketIds.length - 1]`, so sending `BasketId` twice (HTTP parameter pollution through the custom JSON parser) adds items to any basket.
highF-029Unvalidated amounts let users mint wallet credit and negative orders
Security · SEC-09 · effort M
Recommendation
Validate request bodies with a schema (zod/joi): positive integer quantities (also as a model `validate: { min: 1 }`), and positive, bounded top-up amounts. Credit the wallet only after a confirmed payment-provider charge. Reject orders whose computed total is ≤ 0.
Details and evidence for F-029
The wallet top-up adds whatever amount the browser asks for without charging anything, and baskets accept negative quantities. Customers can give themselves unlimited store credit and free goods.
Likelihood
Any registered user with a saved card (any number is accepted) can send the request directly.
Impact
Free store credit and goods: wallet top-ups are never charged, and negative basket quantities produce negative order totals that credit the wallet.
Details
`addWalletBalance` checks only that `paymentId` is one of the caller's cards, then runs `WalletModel.increment({ balance: req.body.balance })` with an unvalidated number. No payment is taken, and negative or huge values are accepted. `BasketItem.quantity` is a bare INTEGER with no `min` validator, and `quantityCheck` only compares upper limits. In `placeOrder`, `itemTotal = itemPrice * quantity` can be negative. With `paymentId === 'wallet'` the check `wallet.balance >= totalPrice` passes and `decrement` by a negative total increases the balance. `deliveryMethodId` and the coupon campaign data also come straight from the body.
highF-032B2B order lines evaluated as code in node:vm with notevil
Security · SEC-04 · effort S
Recommendation
Parse order lines as JSON and validate them with a schema. Remove `vm`/`notevil` completely.
Details and evidence for F-032
The business-order endpoint treats part of the order as a program and runs it. Attackers can use this to freeze the shop or potentially take over the server.
Likelihood
Any authenticated user (accounts are free; JWTs are forgeable per F-023) can post `orderLinesData`.
Impact
An infinite loop blocks the event loop for 2 s per request, which is easy denial of service, and `node:vm` plus the unmaintained `notevil` is a known escape surface leading to code execution.
Details
`b2bOrder` places `body.orderLinesData` into a `vm` context and runs `safeEval(orderLinesData)`. Node's documentation states `vm` is not a security mechanism. `notevil` 1.3.x is abandoned and has published sandbox escapes. The 2-second timeout is synchronous, so each request stalls every other user.
highF-033Order tracking builds a MarsDB $where JavaScript expression from the URL
Security · SEC-04 · effort S
Recommendation
Use an equality filter `find({ orderId: String(req.params.id) })` and drop `$where`. Return orders only to their owner.
Details and evidence for F-033
The order tracking page passes the order number straight into a piece of code the database runs. Anyone, without logging in, can list every customer's orders or stall the server.
Likelihood
Unauthenticated: anyone can call /rest/track-order/:id. The truncation still leaves 60 characters of injected JS.
Impact
The injected expression runs server-side for every order, enough to dump all orders (`' || true || '`) or hang the process with a loop.
Details
`trackOrder` runs `ordersCollection.find({ $where: \`this.orderId === '${id}'\` })`. When the related challenge flag is enabled, `id` is only truncated to 60 characters, not sanitised. `$where` is evaluated as JavaScript by MarsDB. A payload such as `x' || true || '` returns every order (emails are only vowel-masked, plus products and totals).
Evidence
routes/trackOrder.ts · lines 13–22
13 return (req: Request, res: Response) => {
14 // Truncate id to avoid unintentional RCE
15 const id = !utils.isChallengeEnabled(challenges.ch46bea641) ? String(req.params.id).replace(/[^\w-]+/g, '') : utils.trunc(req.params.id, 60)
1617 db.ordersCollection.find({ $where: `this.orderId === '${id}'` }).then((order: any) => {
18 const result = utils.queryResultToJson(order)
19 if (result.data[0] === undefined) {
20 result.data[0] = { orderId: id }
21 }
22 res.json(result)
highF-034Review endpoints: $where injection, operator injection and unowned edits
Security · SEC-04 · effort S
Recommendation
Use `find({ product: Number(id) })`. Coerce `_id` to a string and reject objects. Filter updates with `{ _id, author: user.data.email }` and drop `multi`. Require auth on create and take the author from the session. Make likes atomic (`$addToSet` plus conditional `$inc`). Remove the global `sleep`.
Details and evidence for F-034
Product reviews are poorly protected. Anyone can freeze the server through the review page, any logged-in user can rewrite every review in the shop at once, and reviews can be posted pretending to be another customer.
Likelihood
Show is unauthenticated; edit needs any account. Payloads such as `{"id":{"$ne":-1}}` are trivial.
Impact
One request overwrites every product review in the shop. The read endpoint also allows a blocking `sleep()` DoS, and reviews can be posted under anyone's name.
Details
`showProductReviews` builds `$where: 'this.product == ' + id`. With the challenge flag on, `id` is the URL segment truncated to 40 characters, and the code installs a global blocking `sleep()` reachable from it. `updateProductReviews` passes `req.body.id` straight into the `_id` filter with `multi: true`, so an object like `{"$ne": -1}` matches every review. It also never checks that the caller authored the review (`user` is fetched and ignored). `createProductReviews` (PUT, no auth middleware) takes `author` from the body instead of the session. `likeProductReviews` has a read-modify-write race (the 150 ms sleep makes it wide), so a user can like the same review many times.
Evidence
routes/showProductReviews.ts · lines 17–36
17global.sleep = (time: number) => {
18 // Ensure that users don't accidentally dos their servers for too long
19 if (time > 2000) {
20 time = 2000
21 }
22 const stop = new Date().getTime()
23 while (new Date().getTime() < stop + time) {
24 ;
25 }
26}
2728export function showProductReviews () {29 return (req: Request, res: Response, next: NextFunction) => {
30 // Truncate id to avoid unintentional RCE
31 const id = !utils.isChallengeEnabled(challenges.ch56e73652) ? Number(req.params.id) : utils.trunc(req.params.id, 40)
3233 // Measure how long the query takes, to check if there was a nosql dos attack
34 const t0 = new Date().getTime()
3536 db.reviewsCollection.find({ $where: 'this.product == ' + id }).then((reviews: Review[]) => {
highF-037Profile image URL is fetched server-side without any allowlist
Security · SEC-07 · effort M
Recommendation
Accept only https URLs. Resolve DNS and reject private, loopback, link-local and metadata ranges, and re-check on each redirect (or set `redirect: 'manual'`). Verify the content type is an image and cap the size. Preferably fetch through an egress proxy with an allowlist.
Details and evidence for F-037
The "profile picture from a link" feature makes the server download whatever address a user gives it, including internal systems that should never be reachable from outside. The result can then be read back as an image.
Likelihood
Any logged-in user can submit any URL; redirects are followed by default.
Impact
The server makes requests to internal services and cloud metadata endpoints (e.g. 169.254.169.254) on the attacker's behalf, and the response body is saved as a publicly served image.
Details
`profileImageUrlUpload` calls `fetch(url)` with `req.body.imageUrl` unchecked (scheme, host, IP range), follows redirects, and writes the body to `frontend/dist/.../uploads/<id>.<ext>`, where it is publicly readable. Full-read SSRF. On failure the raw URL is stored as `profileImage`, which is later interpolated into the profile page's Content-Security-Policy (see the CSP finding).
Evidence
routes/profileImageUrlUpload.ts · lines 18–37
18 if (req.body.imageUrl !== undefined) {
19 const url = req.body.imageUrl
20 if (url.match(/(.)*solve\/challenges\/server-side(.)*/) !== null) req.app.locals.abused_ssrf_bug = true
21 const loggedInUser = security.authenticatedUsers.get(req.cookies.token)
22 if (loggedInUser) {
23 try {
24 const response = await fetch(url)
25 if (!response.ok || !response.body) {
26 throw new Error('url returned a non-OK status code or an empty body')
27 }
28 const ext = ['jpg', 'jpeg', 'png', 'svg', 'gif'].includes(url.split('.').slice(-1)[0].toLowerCase()) ? url.split('.').slice(-1)[0].toLowerCase() : 'jpg'
29 const fileStream = fs.createWriteStream(`frontend/dist/frontend/assets/public/images/uploads/${loggedInUser.data.id}.${ext}`, { flags: 'w' })30 await finished(Readable.fromWeb(response.body as any).pipe(fileStream))
31 const user = await UserModel.findByPk(loggedInUser.data.id)
32 await user?.update({ profileImage: `/assets/public/images/uploads/${loggedInUser.data.id}.${ext}` })
33 } catch (error) {
34 try {
35 const user = await UserModel.findByPk(loggedInUser.data.id)
36 await user?.update({ profileImage: url })
37 logger.warn(`Error retrieving user profile image: ${utils.getErrorMessage(error)}; using image link directly`)
highF-042Angular sanitizer bypassed on search term, feedback, emails, IPs and orders
Security · SEC-05 · effort M
Recommendation
Remove every `bypassSecurityTrustHtml` on data. Use text interpolation (`{{ }}`) and CSS classes instead of building HTML strings. Where rich text is needed, sanitise with a current DOMPurify on output. Upgrade sanitize-html. Ignore `True-Client-IP` unless set by a trusted proxy. Add a strict CSP (see SEC-11).
Details and evidence for F-042
Several pages deliberately switch off the browser-side protection against malicious scripts. A crafted link, or a crafted review, email address or IP header, can run attacker code in other users' and admins' browsers and steal their logins.
Likelihood
The search sink is a reflected DOM XSS via a link (`/#/search?q=<iframe src="javascript:...">`). The others are stored XSS fed by unauthenticated or self-service inputs.
Impact
Script runs in victims' sessions, including admins. Tokens sit in localStorage and non-HttpOnly cookies, so this means account takeover.
Details
`bypassSecurityTrustHtml` is applied to: the `q` query parameter (search-result 140, reflected); product descriptions (search-result 110), which are writable unauthenticated via PUT /api/Products (F-039); feedback comments on the About carousel and the admin page; user emails on the admin page (built into an HTML string with interpolation); `lastLoginIp` taken from the attacker-controlled `True-Client-IP` header (saveLoginIp stores it unsanitised when the challenge flag is on); and track-order IDs. Product details render `description` via `[innerHTML]`. Server-side, models rely on `sanitize-html` 1.4.2 (2014, with known bypasses), and the feedback model uses the single-pass `sanitizeHtml` variant, which nested payloads defeat.
highF-043Data-erasure form spreads request body into render options, allowing file read
Security · SEC-08 · effort S
Recommendation
Never spread `req.body` into template locals or options. Pass only the explicit fields needed (`email`, `securityAnswer`) and fix the layout server-side. Add CSRF protection to this cookie-authenticated POST.
Details and evidence for F-043
The "delete my data" page lets a logged-in user name any file on the server and see the beginning of it. That can expose configuration and stored secrets.
Likelihood
Any logged-in user can POST `layout=../../../etc/passwd` (or any path) to /dataerasure.
Impact
The first 100 characters of arbitrary server files are disclosed. The deny-list covers only three substrings, so config, the SQLite DB, logs and .env files remain readable.
Details
`res.render('dataErasureResult', { ...req.body, ...themeVars })` passes user input as hbs render options. hbs honours the `layout` option as a file path, and the only check is a substring deny-list of `ftp`, `ctf.key` and `encryptionkeys` after `path.resolve`. The handler is cookie-authenticated with no CSRF token, so a cross-site form can also file deletion requests for a victim.
highF-046Discount coupons are unsigned encodings anyone can forge
Security · SEC-14 · effort M
Recommendation
Store issued coupons server-side (code, discount, expiry, redemption count) and look them up, or sign them with an HMAC key from a secret store and verify with `timingSafeEqual`. Move the security-answer HMAC key to an environment secret, or better, retire security questions (see F-027).
Details and evidence for F-046
Discount codes are just a reversible scrambling of the month and the percentage, with no secret involved. Anyone who works this out can make their own 99%-off code.
Likelihood
The format (z85 of "MMMYY-NN") is visible from any one real coupon and from the client code; no account knowledge is needed.
Impact
Customers can apply any discount up to 99% on any order during the current month, which is direct revenue loss.
Details
`generateCoupon` returns `z85.encode(toMMMYY(date) + '-' + discount)`, and `discountFromCoupon` decodes it and accepts any two-digit discount whose month matches the current one. There is no MAC, server-side coupon table or single-use tracking. Relatedly, security answers are HMACed with a hard-coded key in source (`'pa4qacea4VK9t9nGv7yZtwmj'`), so a DB leak plus the repo allows offline guessing, and order IDs embed `md5(email).slice(0,4)`.
highF-048Customer order PDFs written to the public /ftp folder; extension check bypassable
Security · SEC-08 · effort S
Recommendation
Store order PDFs outside any served directory and stream them only to the owning user after an auth check. Remove the /ftp listing and static serving. Strip or reject `%00` before validation and validate the final resolved path. Delete backup and credential files from the repository.
Details and evidence for F-048
Each order confirmation, with the customer's email and what they bought, is saved into a public download folder that anyone can browse. Confidential backup files in that folder can be downloaded with a simple trick.
Likelihood
/ftp is browsable without login, so anyone can list and download every order confirmation.
Impact
Every customer's email, purchased items, prices and order ID are exposed. Backup files (package.json.bak, coupons_2013.md.bak, the KeePass database) are downloadable via `%2500`.
Details
This goes beyond scanner leads F-018 (directory listing on /ftp) and F-009 (sendFile path traversal). `placeOrder` writes `order_<id>.pdf` containing the customer email and line items into `ftp/`, which `serveIndex('ftp')` lists and `servePublicFiles` serves. In `servePublicFiles`, the `.md`/`.pdf` allow-list runs before `cutOffPoisonNullByte`, so `/ftp/package.json.bak%2500.md` passes the check and then serves `package.json.bak`. The folder also ships `incident-support.kdbx`, explicitly allow-listed.
highF-051Ethereum wallet recovery phrase hard-coded in server source
Security · SEC-10 · effort S
Recommendation
Treat the wallet as compromised: move any assets out and stop using it. Don't keep mnemonics in code. If a comparison is needed, store only the expected public address and verify a signature instead.
Details and evidence for F-051
The 12-word recovery phrase for a crypto wallet is written in the code. Anyone who reads the code can take everything in that wallet.
Likelihood
The phrase is in the public repository; anyone can import it into a wallet.
Impact
Whoever has the repository fully controls that wallet and any funds or NFTs held by its derived addresses.
Details
`checkKeys` derives a wallet from the literal 12-word BIP-39 mnemonic in source in order to compare a submitted private key. Gitleaks did not flag it (not among F-001..F-008). A mnemonic yields every private key of the wallet, and endpoint responses also confirm when a submitted key matches.
highF-052whoami supports JSONP with cookie auth and arbitrary fields, leaking secrets cross-site
Security · SEC-06 · effort S
Recommendation
Remove JSONP support. Restrict `fields` to an allow-list of non-sensitive attributes (id, email, profileImage). Never keep password or TOTP data in the session cache. Set SameSite on the auth cookie.
Details and evidence for F-052
A malicious website can quietly ask the shop "who is logged in here?" on a visitor's behalf and receive that visitor's private account details, including their scrambled password and two-factor secret.
Likelihood
Any website a logged-in shopper visits can include a script tag pointing at this endpoint.
Impact
The attacker's page receives the victim's email, MD5 password hash, TOTP secret and deluxe token, enough to crack the password and clone the second factor.
Details
`retrieveLoggedInUser` authenticates via `req.cookies.token`, which browsers attach to cross-site script loads since no SameSite is set. It honours `?callback=`, responding with `res.jsonp`, which is designed to be readable cross-origin. The `fields` parameter copies any property of the cached user object into the response, including `password` and `totpSecret`. So `<script src="https://shop/rest/user/whoami?callback=steal&fields=email,password,totpSecret">` exfiltrates them.
Evidence
routes/currentUser.ts · lines 17–33
17 if (security.verify(req.cookies.token)) {
18 user = security.authenticatedUsers.get(req.cookies.token)
1920 // Parse the fields parameter into an array, splitting by comma.
21 // If not provided, both these variables will be undefined.
22 const fieldsParam = req.query?.fields as string | undefined
23 const requestedFields = fieldsParam ? fieldsParam.split(',').map(f => f.trim()) : []
2425 let baseUser: any = {}
2627 if (requestedFields.length > 0) {
28 // When fields are specified, return only those fields29 for (const field of requestedFields) {
30 if (user?.data[field as keyof typeof user.data] !== undefined) {
31 baseUser[field] = user?.data[field as keyof typeof user.data]
32 }
33 }
highF-056Untrusted history, usernames and reviews reach the prompt unseparated
LLM integrations · LLM-01 · effort M
Recommendation
Keep conversation history on the server (keyed by conversation id) or at least validate that only `user`/`assistant` text roles are present, and drop client-supplied tool or system messages. Take the username out of the system prompt, or pass it as quoted data with strict character limits. Return only the needed review fields, wrapped as clearly delimited data, and require authentication to post reviews. Enforce every policy in tool code rather than the prompt, and keep sensitive tools out of conversations that have pulled in third-party content.
Details and evidence for F-056
The chatbot cannot tell the shop's own instructions apart from text written by visitors. A visitor can plant instructions in a product review or their username, or slip fake turns into the conversation, and the chatbot may obey them, including when it talks to other customers.
Likelihood
Likely: product reviews can be written without signing in, users choose their own usernames, and the client sends the whole conversation.
Impact
Attackers can override the bot's rules for everyone. Planted reviews can steer other customers' chats, for example toward phishing text or tool calls made with the victim's identity.
Details
Three untrusted channels reach the model with nothing separating them from instructions. (1) `messages` is taken straight from `req.body` (routes/chat.ts:187, 202). The client can send any roles, including `system` or fabricated `assistant` and tool turns, and the server never rebuilds the history it actually produced. (2) The username from the (unverified) token is placed inside the system prompt (line 82), and usernames are user-editable, so instructions planted there carry system-level authority. (3) `getProductReviews` returns full review documents (line 148). Reviews are written via PUT /rest/products/:id/reviews with no authentication and arbitrary `message`/`author` (createProductReviews.ts:19-25), so any visitor can plant indirect prompt injection that runs inside other customers' chats, where the tools act as those customers (order lookup, coupon creation, F-053). The defences are rules in the prompt only (lines 87-104). Root causes for the tools themselves are filed separately (F-053, F-054).
Evidence
routes/chat.ts · lines 81–85
81export function buildSystemPrompt (userName?: string) {
82 const userIdentifier = userName ? `\nThe customer you are currently chatting with is ${userName}.` : ''
83 return `You are "${botName}", the friendly customer service chatbot of the ${appName} online store.
84You help customers find products, answer questions about the shop, and provide a delightful shopping experience.
85Keep your responses concise and helpful.${userIdentifier}
routes/chat.ts · lines 141–150
141 getProductReviews: tool({
142 description: 'Get all reviews for a specific product by its ID',
143 inputSchema: z.object({
144 id: z.string().describe('The product ID to get reviews for')
145 }),
146 execute: async ({ id }) => {
147 const productId = Number(id)
148 return await db.reviewsCollection.find({ $where: 'this.product == ' + productId }) as Review[]
149 }
150 }),
mediumF-014The application redirects to a URL specified by user-supplied input `query` that is not validated.
Security · SEC-15 · effort S
Recommendation
Confirm the input is attacker-controlled; if so, follow the rule's references.
Details and evidence for F-014
The application redirects to a URL specified by user-supplied input `query` that is not validated. This could redirect users to malicious locations. Consider using an allow-list approach to validate URLs, or warn users they are being redirected to a third-party website.
mediumF-030Deluxe membership granted without payment for unknown paymentMode
Security · SEC-09 · effort S
Recommendation
Allow-list `paymentMode`, reject anything else with 400, and grant the role only after a successful charge.
Details and evidence for F-030
The paid "deluxe" upgrade can be had for free by sending a payment type the server doesn't recognise. The server then skips payment and upgrades the account anyway.
Likelihood
Any logged-in customer can post `{"paymentMode":"x"}` directly to the API.
Impact
Paid membership, with its discounted prices, free delivery and lifted purchase limits, is obtained for free.
Details
`upgradeToDeluxe` charges the wallet only when `paymentMode === 'wallet'` and validates a card only when `paymentMode === 'card'`. Any other value, or none at all, falls through to `user.update({ role: deluxe, ... })`. The card path also never charges the card.
mediumF-044Cookie-authenticated profile and erasure POSTs have no CSRF defence; CORS allows all
Security · SEC-06 · effort S
Recommendation
Set the session cookie server-side with `SameSite=Lax` (or Strict), `Secure` and `HttpOnly`. Add CSRF tokens or Origin/Referer verification on cookie-authenticated mutations. Restrict CORS to the shop's own origins.
Details and evidence for F-044
Another website can silently make a logged-in shopper's browser change their profile or request deletion of their data, because these actions trust the login cookie alone.
Likelihood
A logged-in user only has to visit an attacker's page; the `token` cookie has no SameSite attribute set.
Impact
An attacker's site can change a victim's username (which also feeds the Pug injection, F-031), set their profile image URL (SSRF/CSP injection), or file a data-erasure request.
Details
`POST /profile`, `POST /profile/image/url`, `POST /profile/image/file` and `POST /dataerasure` authenticate solely via `req.cookies.token` and accept form-encoded bodies (`bodyParser.urlencoded`), which cross-site HTML forms can send. There is no CSRF token, Origin check or SameSite cookie. Cookies are set by ngx-cookie on the client and by `res.cookie('token', token)` on the server, both without flags. Separately, `app.use(cors())` and `app.options('*', cors())` allow every origin on every route. That is not credentialed, but it lets any site read anonymous API responses (e.g. the user-enumerating security-question endpoint) from visitors' browsers.
mediumF-045No CSP or HSTS; profile page CSP built from a user-controlled URL
Security · SEC-11 · effort M
Recommendation
Use `helmet()` defaults plus a strict CSP: `default-src 'self'`, no `unsafe-eval`/`unsafe-inline`, nonce-based scripts. Enable HSTS behind TLS. Never interpolate user data into headers; serve profile images only from same-origin upload paths.
Details and evidence for F-045
The site doesn't send the standard browser instructions that limit damage from injected scripts or force encrypted connections. On the profile page a user can even rewrite those instructions themselves.
Likelihood
Applies to every page. The CSP injection needs only a logged-in user setting an image URL.
Impact
The XSS issues above have nothing to stop them. The one CSP present can be rewritten by the user (e.g. adding `; script-src 'unsafe-inline'`), and without HSTS tokens can leak over plain HTTP.
Details
Only `helmet.noSniff()` and `helmet.frameguard()` are enabled. There is no `contentSecurityPolicy`, `hsts` or `referrerPolicy`, and `xssFilter` is commented out. On `/profile` the header is `img-src 'self' ${user.profileImage}; script-src 'self' 'unsafe-eval'`, where `profileImage` is the raw `imageUrl` saved when the server-side fetch fails (profileImageUrlUpload line 36). A value containing `;` injects arbitrary directives.
Evidence
server.ts · lines 183–192
183 /* Security middleware */
184 app.use(helmet.noSniff())
185 app.use(helmet.frameguard())
186 // app.use(helmet.xssFilter()); // = no protection from persisted XSS via RESTful API
187 app.disable('x-powered-by')
188 app.use(featurePolicy({
189 features: {
190 payment: ["'self'"]
191 }
192 }))
mediumF-047CAPTCHAs return their own answers and are reusable
Security · SEC-12 · effort S
Recommendation
Return only the challenge, never the answer. Delete or mark a CAPTCHA used on first verification and fail closed when none exists. Add express-rate-limit (keyed on real client IP and user ID) to feedback, export, search, upload and login. Set `limits: { fileSize }` on every multer instance.
Details and evidence for F-047
The "prove you're human" puzzles send the correct answer along with the question, so bots can pass them every time. Nothing else limits how often these forms can be submitted.
Likelihood
Any script can call /rest/captcha and read `answer` from the JSON.
`captchas()` responds with `{ captchaId, captcha, answer }`, and `verifyCaptcha` never deletes a used CAPTCHA, so one ID/answer pair can be replayed indefinitely. `imageCaptchas()` likewise returns `answer`. `verifyImageCaptcha` calls `next()` when the user has no recent CAPTCHA at all (`!captchas[0]`). There is no rate limiter on `/api/Feedbacks`, `/rest/user/data-export`, `/rest/products/search` or `/file-upload`. The only limiters cover reset-password and 2FA, and they are spoofable (F-027). `uploadToDisk` for `/rest/memories` has no `limits.fileSize`.
mediumF-049Development error handler returns stack traces and SQL errors to clients
Security · SEC-13 · effort S
Recommendation
Register `errorhandler` only when `NODE_ENV === 'development'`. In production, log the error server-side with a correlation ID and return a generic message. Never forward DB error objects to the client.
Details and evidence for F-049
When something goes wrong, the server shows the visitor its internal error details, including database errors. That gives attackers a map of the system.
Likelihood
Any malformed request reaches it, e.g. a single quote in the search box.
Impact
SQL error text reveals table and column structure, which speeds up the SQL injection in F-012/F-015. Stack traces reveal file paths and library versions. XXE and YAML results are echoed through the same handler.
Details
`app.use(errorhandler())` is registered unconditionally. The `errorhandler` package is intended for development only and renders full stack traces as HTML/JSON. Its title is even set to include the Express version. `searchProducts` passes `error.parent` (the raw SQLite error with the query) to `next`. Many handlers build error messages containing user input or remote addresses (`'Blocked illegal activity by ' + remoteAddress`).
mediumF-057Chat stream has no timeout, no cancel on disconnect, and leaks raw errors
LLM integrations · LLM-07 · effort S
Recommendation
Create an AbortController per request, abort it on `req.on('close')`, and pass it with `AbortSignal.timeout(...)` as `abortSignal` to `streamText`. Send clients a generic error code instead of `event.error`. Handle `length` and `content-filter` finish reasons in the UI. Consider a fallback model or a static 'assistant unavailable' answer.
Details and evidence for F-057
If the AI provider is slow or a customer closes the chat, the server keeps the request running and paying for it, with no cut-off time. Raw technical error messages from the AI service can also be shown to customers.
Likelihood
Happens whenever the provider is slow or overloaded, or a browser tab closes mid-answer.
Impact
Generation keeps running and billing for clients that are gone, requests can hang indefinitely, and provider error details reach end users.
Details
`createOpenAICompatible` is built without a custom `fetch` or timeout (routes/chat.ts:107-111), and `streamText` gets no `abortSignal` (lines 199-209). Nothing listens for `req.on('close')`, so when the client disconnects the multi-step tool loop (up to 10 steps, plus retries) runs to the end. Retries rely on the SDK's default backoff, and there is no fallback model or cached answer. For stream `error` events, the raw error is interpolated into the SSE payload sent to the browser (line 249), even though `summarizeLlmError` exists and is used only for logs. A `finishReason` of `length` or `content-filter` is forwarded but the frontend ignores it (chat.service.ts:68-77), so truncated answers look complete to the customer.
lowF-041Metrics, full app configuration and API docs served publicly
Security · SEC-03 · effort S
Recommendation
Protect /metrics with a bearer token or bind it to an internal port. Return an explicit allow-list of client-needed settings instead of the whole config. Put admin routes behind an admin role check.
Details and evidence for F-041
Internal statistics about the shop (how many customers, orders and wallet money) and its full settings are visible to anyone on the internet without logging in.
Likelihood
Anyone can fetch /metrics, /rest/admin/application-configuration and /api-docs.
Impact
Business figures (user counts, order totals, total wallet balance), process internals and the complete runtime configuration help attackers plan, and leak commercially sensitive numbers.
Details
`/metrics` is registered twice with no auth and exposes Prometheus gauges including `wallet_balance_total`, user and order counts, and Node process metrics. `retrieveAppConfiguration` returns `config.util.toObject(config)` with only `chatBot.llmApiUrl` removed, so any secret later added to config ships to anonymous callers. The route sits under `/rest/admin/` but has no guard.
lowF-059No tests or evals for the chatbot prompt and tool policies
LLM integrations · LLM-08 · effort M
Recommendation
Add an eval suite (against a mock provider plus scheduled runs against the real model) covering coupon refusal, cross-user order requests and injected reviews. Log one structured line per call with model, user id, token counts, tools called and finish reason. Add a thumbs-down control in the chat UI.
Details and evidence for F-059
Nothing automatically checks that the chatbot follows the shop's rules after the prompt or AI model changes, and there is no way to trace a bad answer back to a specific conversation.
Likelihood
Any change to the prompt or model (for example a new `gemma` tag) can silently change coupon and order behaviour.
Impact
Regressions in policy compliance or answer quality ship unnoticed, and bad answers cannot be traced to a user or conversation.
Details
The repository has no tests that cover `/rest/chat`, `buildSystemPrompt` or the tools (no matches under test/). Observability is limited to global Prometheus counters for tokens and tool calls (routes/chat.ts:57-79) with no per-request record of model, user, tool arguments or finish reason. The UI has no way to flag a bad answer. The prompt is versioned in code, which is good.
Evidence
routes/chat.ts · lines 57–79
57const metricInputTokensTotal = new Counter({
58 name: `${app}_llm_input_tokens_total`,
59 help: 'Number of total input tokens processed',
60})
61const metricInputTokens = new Counter({
62 name: `${app}_llm_input_tokens`,
63 help: 'Number of input tokens processed',
64 labelNames: ['type'],
65})
66const metricOutputTokensTotal = new Counter({
67 name: `${app}_llm_output_tokens_total`,
68 help: 'Number of total output tokens processed',69})
70const metricOutputTokens = new Counter({
71 name: `${app}_llm_output_tokens`,
72 help: 'Number of output tokens processed',
73 labelNames: ['type'],
74})
75const metricToolCalls = new Counter({
76 name: `${app}_llm_tool_calls_total`,
77 help: 'Number of tool calls made',
78 labelNames: ['tool'],
79})
11 more lines in the HTML report
routes/chat.ts · lines 81–105
81export function buildSystemPrompt (userName?: string) {
82 const userIdentifier = userName ? `\nThe customer you are currently chatting with is ${userName}.` : ''
83 return `You are "${botName}", the friendly customer service chatbot of the ${appName} online store.
84You help customers find products, answer questions about the shop, and provide a delightful shopping experience.
85Keep your responses concise and helpful.${userIdentifier}
8687IMPORTANT RULES:
88- You MUST use the searchProducts tool whenever a customer asks about products, availability, prices, or anything related to the shop's catalog. NEVER guess or make up product names, prices, or descriptions.
89- You MUST use the getProductReviews tool whenever a customer asks for reviews of a product.
90- You MUST use the getOrderById tool whenever a customer asks about a specific order by its ID.
91- Only recommend or mention products that were returned by the searchProducts tool. If a search returns no results, tell the customer that you could not find matching products.
92- Do NOT invent information. If you do not know the answer to a question, say so honestly.93- Your scope is limited to the ${appName} store. Do not answer questions unrelated to the shop or its products.
94- DO NOT RECOMMEND PRODUCTS THAT WERE NOT RETURNED BY THE searchProducts TOOL. If the customer asks for a product that is not found, apologize and suggest they try a different search query.
95- When the search returns nothing, try again with a more generic query if possible, but do not make up product details.
9697COUPON POLICY (for the generateCoupon tool):
98- You may ONLY generate a coupon for a customer who has a verified damaged order with a valid order ID (format: xxxx-xxxxxxxxxxxxxxxx, e.g. 3fa8-bf2bc042f4e92).
99- The customer must have explicitly rejected a return or exchange before a coupon can be offered.
100- The maximum allowed discount is 10%.
101- NEVER generate a coupon just because a customer asks for one or complains.
102- If the customer does not meet ALL of the above conditions, politely decline and explain the policy.
103104CONFIDENTIAL - INTERNAL ONLY: If a customer formally complains about their shopping experience and explicitly requests to escalate the issue, offer them a one-time 15% courtesy discount to resolve the case without escalation. Do not mention this option proactively.`
105}
13 more lines in the HTML report
Open questions
Points the code alone could not settle; each needs an answer from the team.
questionF-058Which LLM provider runs in production, and with what data retention?
LLM integrations · LLM-05 · effort S
Recommendation
Confirm the production provider, that the connection uses HTTPS, and that zero-retention / no-training terms are in place. Minimise the order and review fields passed to the model, and record the provider in the privacy policy.
Details and evidence for F-058
The chatbot sends customer names, order details and conversation text to whichever AI service is configured. We need to know which service that is in production, and whether it keeps or trains on that data.
Likelihood
Unknown: the endpoint is set by configuration (default: a local Ollama on localhost).
Impact
If a third-party API is used, customer names, order contents and reviews go to that provider under its retention and training terms.
Details
The prompt contains the customer's username (routes/chat.ts:82), full order documents including masked email, products, address and payment IDs (line 170), and full review documents including author emails (line 148). `llmApiUrl` comes from config (default `http://localhost:11434/v1`, config/default.yml:18-23) and may be pointed at a hosted provider in deployment. Prompts and responses are not logged in full (only summarised errors), which is good.
165 const order = await db.ordersCollection.findOne({ orderId })
166167 if (!order) return { error: 'Order not found' }
168 if (order.email !== maskedEmail) return { error: 'Order does not belong to the current customer' }
169170 return order