SECURITY AUDIT · 11 JUN 2026

tradeit-backend Security Audit

Full server/ codebase review · 296 JS files · 5-dimension parallel sweep with adversarial verification

Headline risk

Two public-facing flaws drive real-money loss: the crypto deposit webhook can double-credit balance on replay, and Steam login is an open redirect.

Both are reachable without any admin gating. Fix Findings 4 & 5 first.

Network context (provided post-audit): /internal and /commands are gated by Cloudflare Zero Trust Access — admin host only, company-email SSO + admin-system user/pass, whose backend issues the requests. /cron is restricted to the internal cron service. This closes the external attack path, so Findings 1–3 are downgraded to Low (defense-in-depth). Residual: the admin-backend→tradeit-backend hop is server-to-server — if it does not also traverse CF Access, the app layer remains its only (absent) control, exposed to SSRF or internal lateral movement.

2
High
3
Medium
3
Low / DiD
12
Areas Clean

Branch note: The audited branch hotfix/claude-graphify diff itself (graphify output + a .claude/settings.json Bash hook) contains no security issues. All findings below are pre-existing in the codebase, surfaced by the full-project audit Ehud requested.


High Severity

Directly exploitable on public endpoints · no admin gating in front

04 Crypto deposit webhook has no idempotency → balance double-credit High
server/service/cryptoService.js:160–290 (route routes/crypto.js:6, no auth)
Category payment_replayConfidence 9/10

What — Validates a deterministic sha256(status+type+address+amount+secret+tx_id) hash, then on confirmed credits amount_usd × bonus and inserts a topup — without checking whether tx_id was already processed. The Stripe path guards via getByOrderId (stripeProcessService.js:31–43); crypto path does not.

Exploit — Any re-delivery of a valid confirmed body (provider retry, at-least-once delivery, captured/leaked request) re-validates the identical hash and re-credits real-money balance. No crafted value needed.

Fix — Look up tx_id before crediting; perform check + insert in a transaction with a unique constraint on order_id/tx_id, mirroring Stripe.

05 Open redirect on Steam login High
server/controllers/steam.js:40 (route routes/steam.js:11)
Category open_redirectConfidence 9/10

What/login reads redirectTo from the query, atob()-decodes it, and passes it straight to res.redirect(atob(redirectTo)). loginValidation validates only the steamId — no allowlist / relative-path check. Base64 is obfuscation, not validation.

Exploit — Crafted ?redirectTo=<base64("https://evil.com/phish")> → victim authenticates with their real Steam session, then is 302'd to the attacker site (post-login phishing + Referer / auth-context leak).

Fix — After decode, require a single-leading-/ relative path (reject //, /\, absolute URLs) or allowlist internal paths.


Medium Severity

Requires specific conditions, or lower real-world impact

06 siteDisabled kill-switch bypass via substring match Medium
server/index.js:117–127
Category broken_access_controlConfidence 8/10

WhatallowPaths = ['lootbear','internal','command','cron'] checked via req.originalUrl.includes(path). Naive substring → any URL containing those words (e.g. /api/v2/user/data?x=command) bypasses the emergency site-disable. Also confirms the privileged routes are intentionally exempt from the only global gate — amplifies Findings 1–3.

Fix — Match on mounted prefix (req.path.startsWith('/api/v2/internal')) and apply real auth so the exemption is no longer a free pass.

07 Weak randomness for trade / sale tokens Medium
sellService.js:177 · tradeService.js:246, :361, :393
Category weak_cryptoConfidence 7/10

What — Tokens = Math.floor(Math.random() * 1e8): non-CSPRNG (V8 xorshift128+, seed-recoverable), 108 keyspace, published on the global Redis tradestate pub/sub channel. Impact bounded by how downstream consumers trust the token; no inbound money-gating validation found.

Fixcrypto.randomBytes(16).toString('hex') / crypto.randomUUID() for any value crossing a trust boundary.

08 Unescaped user input in Contact-Us email Medium
server/service/contactUsService.js:10–13 (route POST /api/v2/contact-us)
Category html_injectionConfidence 7/10

What — Public endpoint (reCaptcha only) interpolates email / subject / message unescaped into the email HTML and the from header. Real but low impact: mail clients sandbox HTML (no JS), and nodemailer encodes address headers — so staff-facing HTML/phishing injection, header injection likely neutralized.

Fix — HTML-escape before interpolation (or send as text); validate email with a strict regex.


Low — Defense-in-Depth

External path closed by network controls · in-code auth still recommended for the residual hops below

01 /api/v2/commands admin surface has no app-layer auth Low / DiD
server/routes/command.js (mount routes/index.js:176) · controllers/command.js:117
Category broken_access_controlMitigation Cloudflare Zero Trust (admin host, company SSO + user/pass)

What — 20 admin endpoints (add-balance, ban-user, restart) carry zero middleware; adminId is read from req.body. External exploitation is blocked by CF Access in front.

Residual — The admin-system backend calls these routes server-to-server. If that hop does not also traverse CF Access, an SSRF in our backend or a foothold on any host that can reach the Node process bypasses the gate entirely. Also, adminId attribution is unverifiable.

Fix (optional) — Lightweight constant-time internal shared-secret on the router; derive adminId from the authenticated principal, not the body.

02 /api/v2/internal balance & payout endpoints have no app-layer auth Low / DiD
server/routes/internal.js (mount routes/index.js:177) · controllers/internal.js:1053, :1074, :245, :414
Category broken_access_control / IDORMitigation Cloudflare Zero Trust (same admin gate)

WhatremoveUserBalance, addPendingAddableBalance, processWithdrawSell, oauth2ConsumeBalance mutate balances / payouts from req.body; gated externally by CF Access.

Residual — Same server-to-server hop concern as Finding 01 — these are the highest-value mutations in the app, so the defense-in-depth case is strongest here.

Fix (optional) — Internal shared-secret / mTLS on the router so the app layer is not the only (absent) control on the internal hop.

03 /api/v2/cron financial / rollback endpoints have no app-layer auth Low / DiD
server/routes/cron.js (mount routes/index.js:171)
Category broken_access_controlMitigation Restricted to internal cron service

What — 35 endpoints (refundBlockIds, cleanReservedItems, :gameId/market recompute) reachable only from the internal cron service.

Residual — Any internal-network foothold that can reach the app can trigger refunds / market recompute, since there is no second check. Lower priority than 01–02.

Fix (optional) — Signed-request header from the cron service if defense-in-depth is wanted on the internal hop.


Verified Clean

Areas examined and cleared — existing defenses are sound

SQL
Parameterized throughout — named binds / ?, zero string-concat. ${...} occurrences are booleans / hardcoded columns / enum-whitelisted ORDER BY.

CmdInj
None — no child_process / exec / spawn anywhere in server/share/utils/scripts.

Eval
None — no eval, new Function, or vm usage.

PathTrav
LOAD DATA path hardened: regex filename allowlist + realpath confinement + O_EXCL (infileStreamFactory.js).

Webhooks
Stripe / NowPayments / Monnect all signature-verified and idempotent, amounts server-side. (Crypto is the exception — Finding 4.)

Secrets
No hardcoded secrets — dotenv-vault + process.env throughout; Dockerfile uses BuildKit secret mounts, runs non-root.

Crypto
AES-256-CBC w/ random IV via createCipheriv (not deprecated createCipher, not ECB). No md5/sha1 for security.

SSRF
None — outbound hosts from env / hardcoded; OAuth2 webhook URL is trusted DB partner config, not user input.

Deser.
None — only JSON.parse; no node-serialize, yaml.load, or prototype-pollution merges.

CORS
No origin reflection. Restrictive CSP + HSTS + nosniff set globally.

Redis
No KEYS in production paths; bulk deletes via hDel.

IDOR
Stripe getTopupTransaction session-scoped (getByOrderId(orderId, steamId)) — not exploitable.


Remediation Order

PriorityActionSeverityConfidence
1Add idempotency guard + unique constraint to crypto deposit credit path (Finding 4)High9/10
2Validate redirectTo to same-site relative paths on Steam login (Finding 5)High9/10
3Replace originalUrl.includes() with prefix match on the site-disable gate (Finding 6)Medium8/10
4Swap Math.random() trade/sale tokens for crypto.randomBytes (Finding 7)Medium7/10
5Escape / validate Contact-Us email inputs (Finding 8)Medium7/10
6Confirm admin-backend→tradeit-backend hop traverses CF Access; if not, add internal shared-secret to /internal + /commands (Findings 1–3, defense-in-depth)Low / DiD

Generated 11 Jun 2026 · tradeit.gg · Methodology: 5 parallel domain agents (injection / authz / payments / secrets-crypto / web-misc) + 2 adversarial verification passes · confidence ≥ 7 reported