Launchprep launchprep.
readiness scan for AI-built apps

A real report, start to finish

What a deep scan actually produces — 35 findings in OWASP Juice Shop, unedited.

This is a real, unedited deep scan of juice-shop/juice-shop — OWASP’s deliberately vulnerable training app, 13,750 stars, MIT. Commit 1618a611b173, scanned 2026-08-31.

Its flaws are put there on purpose, which is why it is safe to publish this. Nothing was added, removed or reworded to make the output look better.

Clone it and run npx launchprep yourself — the free half of this is reproducible on your own machine in a minute.

type web-appstack express · postgresaccounts yesAI yesuploads yesdata piistage production
35findings
5critical
17high
13medium
69deep checks asked
critical

Recycle record lookup has no ownership check

routes/recycles.ts:12 · AUTHZ-003

Any authenticated (or in some deployments unauthenticated) visitor can pass an arbitrary numeric id in the URL and receive another customer's recycling request, which includes their address reference and pickup details. The query filters only on id, never on the requester's UserId.

Fix  Add a where clause that also requires UserId to match the logged-in user's id (from the verified JWT), and return 403/404 on mismatch.

critical

Basket contents returned for any basket id without ownership check

routes/basket.ts:17 · AUTHZ-003

retrieveBasket loads a basket purely by the :id route parameter; the only place the owning user is checked is inside a challenge-detection callback that only sets a flag for the training challenge, it never blocks the response. Any logged-in customer can view the full cart contents (products, quantities) of any other customer by changing the basket id in the URL.

Fix  Before returning the basket, verify that the basket's UserId equals the id of the currently authenticated user (as already computed for the challenge check) and return 403 if it does not.

critical

Wallet top-up amount is never validated

routes/wallet.ts:27 · DATA-006

Any logged-in customer can call the wallet top-up endpoint with an arbitrary 'balance' value (any number, including huge or negative amounts) after only proving they own a stored card. Nothing checks that the amount matches a real payment, so a customer's wallet balance can be inflated indefinitely for free.

Fix  Validate req.body.balance server-side against an allow-listed set of top-up amounts (or verify it against an actual payment-provider charge amount) before calling WalletModel.increment.

critical

No backup plan for the only persistent data store

infrastructure/docker-compose.yml:77 · DATA-012

The juice-shop-data volume (holding the SQLite database) and mongo-data volume are declared with 'driver: local' and nothing else. If the host disk or container is lost, all user accounts, orders, wallets, and addresses are gone permanently, and there is no documented or automated way to restore them.

Fix  Add a scheduled backup step (e.g. a sidecar/cron job dumping the SQLite file and Mongo collections to external storage, or use AWS Backup on the Terraform EFS volume) and document/test a restore procedure.

critical

Unrestricted server-side fetch of user-supplied URL

routes/profileImageUrlUpload.ts:24 · API-014

Any authenticated user (registration is open) can submit an arbitrary URL and the server will fetch it, allowing the app server to be used to probe or reach internal-only endpoints (e.g. cloud metadata services, internal admin panels) that are not reachable from the public internet directly. The result is even written back into the user's own profile image or error message, giving partial response feedback.

Fix  Resolve the URL's host before fetching and reject requests to private/link-local IP ranges (RFC1918, 169.254.0.0/16, etc.) and non-HTTP(S) schemes, or restrict image fetching to an explicit allowlist of trusted domains.

high

Memory record UserId taken from request body without validation

routes/memory.ts:15 · DATA-006

The 'Add Memory' endpoint stores whatever UserId value is sent in the request body as the owner of the new memory row, without checking it against the authenticated user or even that it is a valid integer. A user could attach a photo/caption to someone else's account.

Fix  Derive UserId from the authenticated session (as done elsewhere via security.authenticatedUsers.from(req)) instead of trusting req.body.UserId, and validate it is a number that exists.

high

Profile pictures are saved under a public, unauthenticated static path

routes/profileImageFileUpload.ts:40 · DATA-013

Uploaded profile images are written to frontend/dist/frontend/assets/public/images/uploads/<userId>.<ext>, a directory served statically to anyone with no login check. Since the filename is just the sequential user ID, any visitor can enumerate other users' profile photos simply by requesting /assets/public/images/uploads/1.png, /2.png, etc.

Fix  Store uploaded images outside the public static root and serve them through an authenticated route that checks the requester is allowed to view that user's image, or use unguessable per-file tokens.

high

Deployments use a mutable 'latest' image tag with no rollback mechanism

infrastructure/terraform/variables.tf:64 · DEP-007

The container_tag variable defaults to 'latest' and the ECS task definition always references this same tag. If a bad release is deployed, the previous working image has already been overwritten in the registry, so there is no way to redeploy the last-known-good version without rebuilding it from source.

Fix  Tag images with immutable version identifiers (e.g. git SHA or semver) and keep the ECS task definition parameterized on that tag so a previous revision can be redeployed directly as a rollback.

high

Auth token library pinned to an ancient, abandoned version

package.json:129 · DEP-012

lib/insecurity.ts uses this exact 'jsonwebtoken' package to sign and verify every user's session token. Version 0.4.0 predates years of security fixes to the jsonwebtoken library (algorithm-confusion and verification issues in that era), so any customer's authentication token handling relies on a dependency that no longer receives security patches.

Fix  Upgrade to a current, maintained major version of jsonwebtoken (and express-jwt) and adjust the signing/verification calls to the modern API.

high

Production data volume has no backup policy and no restore drill

infrastructure/terraform/networking.tf:249 · DEP-018

The aws_efs_file_system resource holding the application's SQLite database is created with no aws_efs_backup_policy and no snapshot/backup automation. If the EFS filesystem is corrupted or accidentally deleted, all user, order, and product data is gone with no way to restore it, and there is no evidence anyone has ever tested a restore.

Fix  Attach an aws_efs_backup_policy (or AWS Backup plan) to the EFS filesystem and document/periodically run a restore drill that recovers data into a test environment to prove backups are usable.

high

SVG profile images are accepted and served same-origin

routes/profileImageFileUpload.ts:29 · UP-004

The upload handler only checks that the detected MIME type starts with 'image', which is true for image/svg+xml. An SVG can embed <script> or event-handler XSS payloads. Because the file is saved under frontend/dist/frontend/assets/public/images/uploads/ and served from the app's own origin, visiting the resulting profile image URL executes the attacker's script in the context of the Juice Shop domain, letting an attacker steal other users' session tokens.

Fix  Reject SVG uploads (allowlist only raster formats like png/jpg/gif/webp) or sanitize SVG content and serve uploaded images from a separate, script-disabled origin/CDN with a strict Content-Security-Policy and Content-Disposition: attachment.

high

URL-based profile image import also allows SVG, served same-origin

routes/profileImageUrlUpload.ts:28 · UP-004

When a user submits an imageUrl, the extension is chosen from an allowlist that includes 'svg', and the fetched file is written unmodified into the same public uploads directory. Any attacker-hosted SVG with a script payload becomes same-origin stored XSS for the victim who set it as their own profile picture (or whoever later views it).

Fix  Remove 'svg' from the allowed extension list for imported profile images, or run any accepted SVG through a strict sanitizer before storage.

high

Zip extraction has no size or entry-count bounds

routes/fileUpload.ts:28 · UP-012

extractZipBuffer opens and iterates every entry of an uploaded zip and writes each one to disk with no cap on the number of files, their individual size, or the total decompressed size. A crafted zip bomb or an archive with millions of tiny entries can exhaust disk space or file handles on the server, affecting all users of the shop.

Fix  Before extracting, enforce limits on total entry count, per-entry uncompressed size, and total uncompressed size (reject or abort extraction if exceeded), and validate that resolved paths stay within the target directory.

high

Path traversal guard on extracted entries is weak

routes/fileUpload.ts:34 · UP-012

The only check before writing an extracted file is that the resolved absolute path 'includes' the current working directory path, which does not reliably prevent traversal to sibling directories still under the cwd tree, and the code is explicitly designed to let a crafted zip write to ftp/legal.md for the fileWriteChallenge. Combined with no bound on extraction, an attacker-supplied archive can write many files anywhere reachable under the working directory.

Fix  Resolve each entry path with path.resolve and verify with path.relative that it does not start with '..' relative to the intended target directory, rejecting any entry that would escape it.

high

No way to revoke an issued session/token

lib/insecurity.ts:188 · INC-001

If a user's JWT is stolen (e.g. via XSS) or a password is changed, the token remains fully valid for up to 6 hours because any request bearing a signature-valid JWT that isn't in the in-memory map gets silently re-added and trusted here. There is no server-side blacklist, token version, or 'invalidate all sessions' capability for a user or admin to use during an incident.

Fix  Add a persisted token-version or session-id claim per user that is bumped on password change/logout/'log out everywhere', and reject tokens whose version does not match the current value stored on the user record.

high

Password change does not revoke other active sessions

routes/changePassword.ts:47 · INC-001

After a user changes their password, the JWT they were previously issued (and any other active sessions/tokens for that account) keep working unchanged, so an attacker who already holds a stolen token is not locked out by the victim's remediation attempt.

Fix  On password change, increment a per-user token version/generation counter and check it on every authenticated request so older tokens are rejected immediately.

high

No runtime toggle for the remote-image-fetch feature

routes/profileImageUrlUpload.ts:24 · INC-002

This endpoint lets any logged-in user make the server fetch an arbitrary attacker-supplied URL. If this is abused in production (e.g. to probe internal infrastructure), there is no configuration switch or feature flag to disable the endpoint without a code change and redeploy.

Fix  Gate this handler behind a config flag (e.g. features.allowRemoteProfileImage) that can be flipped via environment/config reload, and add an allowlist or deny-private-IP check regardless.

high

Wallet balance can be increased by an arbitrary attacker-controlled amount with no monitoring

routes/wallet.ts:27 · INC-006

addWalletBalance only checks that the supplied card belongs to the user; the amount added to the wallet comes directly from req.body.balance with no upper bound, and no log entry or alert is generated for large or repeated top-ups. A user (or automated script) could inflate their balance repeatedly and nobody would notice until someone happened to look.

Fix  Enforce a sane maximum per-transaction and per-day top-up amount server-side, and emit a structured log/alert event whenever a top-up exceeds a threshold so finance/security can be notified in near real time.

high

Logging out does not revoke the session token on the server

lib/insecurity.ts:68 · AUTH-007

authenticatedUsers only exposes put/get/tokenOf/from/updateFrom — there is no delete/invalidate method, so nothing in the codebase can actually kill a session server-side. A stolen or 'logged out' token stays cryptographically valid (RS256, 6h expiry) and keeps working against every endpoint that checks it, so a user who logs out on a shared computer has not actually ended their session for anyone with the old token/cookie.

Fix  Add a token blacklist/revocation store (or move to short-lived tokens plus a rotating refresh token) and clear the entry from authenticatedUsers.tokenMap on logout.

high

Any request re-inserts a stale JWT into the authenticated-users map

lib/insecurity.ts:121 · AUTH-007

updateAuthenticatedUsers verifies the JWT signature and, if valid, calls authenticatedUsers.put(token, decoded) again regardless of whether that token had ever been removed. Even if a future logout handler deleted the entry, presenting the same still-unexpired JWT (e.g. replayed from browser history or a proxy log) restores full access for up to 6 hours.

Fix  Do not repopulate the authenticated-users map purely from JWT signature validity; check a persisted revocation list before treating a token as live again.

high

Username change accepts cookie auth with no CSRF protection

routes/updateUserProfile.ts:38 · API-007

An attacker can host a page that auto-submits a form to this endpoint; because the browser will send the victim's `token` cookie automatically, the victim's username gets changed without their consent. The only origin/referer check present (lines 31-36) exists solely to detect and flag the CSRF challenge as solved — it does not block or reject the request.

Fix  Require a per-session CSRF token (e.g. via csurf or a double-submit cookie) on all cookie-authenticated state-changing routes, or stop accepting the JWT via cookie for mutations and require the Authorization header only.

high

Profile image upload via URL is cookie-authenticated with no CSRF check

routes/profileImageUrlUpload.ts:22 · API-007

Like the username-change endpoint, this state-changing action (fetching a remote image and overwriting the user's profile image) is triggered purely by the `token` cookie with no CSRF token, letting a third-party site silently force a logged-in victim's browser to invoke it.

Fix  Add CSRF token validation for this route or move to header-only JWT authentication for all mutating endpoints.

medium

No automated retention/purge policy for personal data

models/privacyRequests.ts:24 · LIFE-001

Addresses, cards, security answers, orders and feedback are stored indefinitely. The only privacy mechanism visible is a boolean 'deletionRequested' flag on PrivacyRequestModel; nothing in the codebase schedules deletion, anonymization, or expiry of this data even after a request is recorded.

Fix  Add a scheduled job that processes PrivacyRequestModel entries and deletes/anonymizes the associated user's records across all tables and Mongo collections, and define explicit retention windows for orders, feedback and logs.

medium

Blocking busy-wait loop freezes the whole server

routes/showProductReviews.ts:23 · DATA-011

The global.sleep helper spins in a synchronous while-loop for up to 2 seconds to simulate MongoDB's blocking behavior. Because Node.js is single-threaded, every other request being served by the process stalls for the same duration while this runs, so one slow review-timing request can freeze the entire shop for all concurrent visitors.

Fix  Replace the busy-wait with a non-blocking delay (e.g. setTimeout/await) or move the timing-sensitive logic to a worker thread so it cannot stall the main event loop.

medium

Account enumeration via security-question lookup

routes/securityQuestion.ts:21 · RU-018

Anyone can submit an arbitrary email address to /rest/user/security-question and learn from the response shape (a security question object vs. an empty object) whether that email belongs to a registered account. There is no rate limiting or throttling on this endpoint, so an attacker can enumerate the full customer email list at will.

Fix  Return the same generic response (e.g. a fixed placeholder question or a 404) regardless of whether the email is registered, and add rate limiting/CAPTCHA to the endpoint.

medium

Unlimited memory image uploads per user

routes/memory.ts:17 · UP-010

Any authenticated user can call this endpoint repeatedly to upload arbitrarily many images with no count or total-size limit, letting a single account fill server disk space indefinitely.

Fix  Track cumulative storage used per UserId (count and/or bytes) and reject new uploads once a configured quota is exceeded.

medium

Complaint file attachments have no per-user limit

models/complaint.ts:16 · UP-010

The Complaint model stores an arbitrary file path per complaint with no cap on how many complaints (and attached files) one user can create, allowing storage exhaustion by a single account submitting many complaints.

Fix  Enforce a maximum number of complaints or total attachment size per user, checked before accepting a new upload.

medium

Failed logins are silently rejected with no tracking or alerting

routes/login.ts:50 · INC-007

Every incorrect login attempt returns a 401 with no counter, log line, or rate limit tied to the email or IP, so a large-scale password-spraying or brute-force attack against customer accounts produces no signal an operator could act on. (The project's own code-fix examples for a different endpoint, resetPasswordMortyChallenge, show that rate limiting is treated as an optional 'fix', not something enabled by default.)

Fix  Add rate limiting per account/IP on /rest/user/login and emit a metric/log event on repeated failures so an auth-failure-spike alert can be built on top of it.

medium

Users can register with any email address, verified or not

models/user.ts:33 · AUTH-005

The User model stores email, password and role with no verified/confirmed flag, and no companion model (e.g. an email-verification-token table) exists anywhere in models/index.ts. Anyone can sign up using someone else's real email address (e.g. a coworker's or a public figure's) and the account is immediately usable, no confirmation link required.

Fix  Add an emailVerified flag to the User model, send a confirmation link on registration, and block login (or at least mark the account) until the link is clicked.

medium

Login endpoint has no throttling or breach/anomaly checks

routes/login.ts:36 · AUTH-009

The login handler runs the password query and returns 401 on mismatch with no attempt counter, no CAPTCHA, no IP/device throttling, and no check against known-breached password lists. An attacker can script unlimited password guesses or credential-stuffing attempts against any account with no server-side friction.

Fix  Add rate limiting (per-IP and per-account) and a lockout/backoff mechanism on /rest/user/login, and consider checking new passwords against a breached-password list at registration/change time (a related check already exists as a coding-challenge fix for the password-reset endpoint, but nothing equivalent protects login itself).

medium

Hardcoded, non-rotatable JWT signing key

lib/insecurity.ts:21 · SEC-007

Every user session token is signed with one hardcoded private key baked into the source file, and the matching public key is read from a single static file (encryptionkeys/jwt.pub). There is no key identifier in the token, no support for a second/next key, and no process shown for rotating this key if it were ever exposed — doing so today would require a code change and redeploy, invalidating every logged-in user with no graceful transition.

Fix  Move the signing key out of source into a secrets manager, add a 'kid' header and support validating against a small set of current+previous public keys so the private key can be rotated without a hard cutover, and document the rotation runbook.

medium

Failed login attempts are not logged

routes/login.ts:47 · OBS-002

When a customer or attacker submits a wrong email/password, the server just responds 401 'Invalid email or password.' with no call to the logger. There is no way for an operator to see a spike of failed logins that would indicate brute-forcing or credential stuffing against real customer accounts.

Fix  Add a logger.warn(...) call (with source IP, not the password) on every failed authentication attempt in the login handler.

medium

Authorization denials are silently returned to the caller only

lib/insecurity.ts:146 · OBS-002

isAccounting() responds with a 403 'Malicious activity detected' when a non-accounting user hits an accounting-only endpoint, but nothing is logged server-side, so repeated privilege-escalation attempts against real user accounts leave no trace for an operator to notice.

Fix  Log denied authorization attempts (user id, route, timestamp) via the existing winston logger before returning the 403.

medium

Cross-account resource access attempts are not logged

routes/payment.ts:58 · OBS-002

getPaymentMethodById/delPaymentMethodById return 'Malicious activity detected' when a UserId/id combination doesn't match, but this suspicious event (someone probing another customer's card records) is never written to a log, so an operator cannot detect an ongoing enumeration attack.

Fix  Log these 400 'Malicious activity detected' responses with the requesting user id and target id so repeated probing is visible in logs/alerts.

medium

No alerting configured for service health or downtime

infrastructure/terraform/main.tf:128 · OBS-004

The ALB target group has a health check and CloudWatch Container Insights is enabled on the ECS cluster, but there is no aws_cloudwatch_metric_alarm, SNS topic, or similar resource anywhere in the Terraform to notify anyone when the service becomes unhealthy or task count drops to zero. If the app goes down outside business hours, ECS will keep retrying but no human is notified, so an outage could run for hours before anyone notices.

Fix  Add a CloudWatch alarm on ECS service RunningTaskCount / ALB UnHealthyHostCount wired to an SNS topic (email/Slack/PagerDuty) so downtime triggers a page instead of silent auto-restarts.

What the scan could and could not settle

69 deep checks applied to this app. Of those, 25 found something, 4 came back clean, 8 did not apply to what it is, and 32 could not be settled from the code that was read.

That last number is reported rather than hidden. A check that could not reach an answer has not passed, and a report that quietly counts it as one is telling you you are safe when nobody looked.

This repository is larger than one scan holds: 159 of 427 ranked files were read, most security-relevant first. The next scan of the same project starts with the rest.

Run the free checks on your own project, on your machine, unlimited:

npx launchprep

The deep scan above is the paid half — $29 once, five scans.

Run every check that applies to your project, on your machine, free and unlimited:

npx launchprep