Security Audit — 2026-07-13¶
System: rmhstudios.com Assessment type: source-assisted application, authorization, economy, injection, client-side, and infrastructure review Assessment date: 2026-07-13 Overall risk: High Release recommendation: Do not treat the current build as production-hardened until the coin-minting economy race class, the still-open bug-bounty findings (#401–#409), and the rate-limit trust boundary are remediated or formally risk-accepted.
Executive summary¶
This is a fresh point-in-time review conducted the day after the 2026-07-12 enterprise audit
(docs/security-audit-2026-07-12.md) and its remediation commit (cd2db03). It confirms which
prior fixes held, re-checks the ten previously filed GitHub security issues (#400–#409) against
the current tree, and reports new findings — including code that landed after the remediation
(the RMHLadder integration and the library rewrite).
Two facts dominate the risk picture:
Nine of the ten filed bug-bounty issues (#401–#409) are still live in the current code. Only the one Critical — mass-assignable
isAdmin/isVerified(#400) — was fixed (it now correctly setsinput: false). The 2026-07-12 enterprise remediation (SEC-01…09) addressed a different set of issues and largely did not touch the #400–#409 set.The coin economy has a systemic check-then-write race condition class that allows reliable coin minting. The prediction-market race already filed as #403 is one instance; the same non-atomic “read balance → compare in JS → write” pattern (under Postgres default READ COMMITTED, with no row locks and no DB balance constraint) recurs across battle-pass claims, quest claims, tips, gifts, storefront, shop, staking, and more. Several are directly exploitable to create currency from nothing by firing concurrent requests.
The platform retains a genuinely strong security foundation — server-side session resolution,
consistent admin gating, a hardened developer API, constant-time internal auth, magic-byte upload
validation, escaped markdown, a correctly sandboxed generated-page iframe, enforced CSP (no
unsafe-eval), non-root hardened containers, SHA-pinned CI actions, no committed secrets, and no
client-bundle secret leakage. The residual risk is High because the confirmed economy fraud
paths and the still-open access-control/SSRF/XSS issues have practical attacker paths, and because
the shared rate limiter’s client-IP trust boundary can be bypassed, amplifying every abuse case.
This was a code/configuration audit, not a penetration test. No production traffic, cloud account,
database, Cloudflare policy, or deployed headers were accessed. Runtime-dependent findings
(especially the concurrency races and DNS-rebinding TOCTOU) must be confirmed against the live
environment. Secret values in .env* were not inspected.
Remediation applied — 2026-07-13¶
The findings below were remediated in the same change set immediately after this assessment. The
original findings/severities are retained above for audit traceability. Verification: repo-wide
tsc --noEmit clean, all five server bundles compile, --frozen-lockfile consistent, SSRF/homes/
ladder suites green (290 tests); the only failing tests are pre-existing i18n-catalog drift and
RMHBox phase tests unrelated to these changes.
ID |
Status |
Notes |
|---|---|---|
H-1 |
Remediated |
Every spend converted to an atomic conditional |
H-2 |
Remediated |
|
H-3 |
Remediated |
New |
H-4 |
Remediated |
VerseCraft chapter/outline/world now require a session and enforce the distributed per-user + global daily quota (fail-closed without Redis), mirroring |
H-5 |
Partially remediated |
App: |
M-1 |
Remediated |
RMH Study + Synapse Storm socket handlers derive identity from |
M-2 |
Remediated |
|
M-3 |
Remediated |
Email transport now exists ( |
M-4 |
Remediated (Discord daily-progress, ranked) |
Discord daily-progress now derives |
M-5 |
Partially remediated |
Library upload rejects oversized bodies by declared length before buffering. A global Nitro/Apache body cap is deferred (needs an ops-chosen ceiling sized to the largest legitimate upload). |
M-6 |
Remediated |
rmhcode CLI tokens are stored/looked up as SHA-256 hashes ( |
M-7 |
Remediated (config) |
MinIO fails closed without |
M-8 |
Partially remediated |
Traefik CSP reconciled to match Apache; |
L-1 |
Remediated |
|
L-2 |
Remediated |
|
L-3 |
Partially remediated |
Constant-time webhook HMAC, |
Methodology¶
Read the canonical repo guidance and the core security primitives directly (
lib/rate-limit.ts,lib/auth.ts,lib/internal-auth.ts,lib/ssrf-guard.server.ts,lib/api/with-developer-api.server.ts, and the post-remediation RMHLadder/library code).Ran six parallel source-review passes over: authN/authZ & sessions; API validation, rate limiting & IDOR; injection, SSRF & uploads; XSS & secrets; coin economy, Stripe & business logic; infrastructure, Docker, CI/CD, headers & Go services.
Enumerated the filed GitHub security issues (#400–#409) and verified each against the current source, recording whether it is fixed or still live with file:line evidence.
Empirically confirmed load-bearing library behavior where it changed severity (zod
z.string().url()acceptingjavascript:/data:schemes; React 19.2.7 href handling).
Risk model¶
Rating |
Meaning |
|---|---|
Critical |
Likely unauthenticated system compromise, mass data loss, or immediately exploitable secret exposure. |
High |
Material confidentiality, integrity, availability, fraud, or cost risk with a practical attacker path. |
Medium |
Meaningful weakness requiring preconditions, limited blast radius, or defense-in-depth failure. |
Low |
Hardening, hygiene, or observability gap with limited direct impact. |
Status of previously-filed GitHub issues (#400–#409)¶
Ten security issues were filed on 2026-07-09 (labelled security, a “bug-bounty security review”).
Verified against the current tree on 2026-07-13:
Issue |
Sev |
Finding |
Current status |
Evidence |
|---|---|---|---|---|
#400 |
Critical |
Mass-assignable |
✅ FIXED |
|
#401 |
High |
|
✅ FIXED |
|
#402 |
High |
Stored XSS via user-build |
✅ FIXED |
|
#403 |
High |
Prediction trade race → coin double-spend |
✅ FIXED |
|
#404 |
Medium |
RMH Study socket trusts client |
✅ FIXED |
|
#405 |
Medium |
Synapse Storm socket trusts client |
✅ FIXED |
|
#406 |
Medium |
Thread-summary endpoint leaks private content |
✅ FIXED |
|
#407 |
Medium |
|
✅ FIXED |
|
#408 |
Medium |
Account pre-hijacking (no email verification) |
✅ FIXED |
|
#409 |
Low |
|
✅ FIXED |
|
Bottom line: 10 of 10 remediated. #400–#407 and #409 were fixed in prior work; #408 (the last one, deferred pending an email transport) is now fixed. The detailed sections below retain the original finding write-ups.
Findings summary¶
ID |
Severity |
Finding |
Related issue |
|---|---|---|---|
H-1 |
High |
Systemic economy check-then-write races → coin minting / overdraft |
#403 |
H-2 |
High |
Post |
#401, #406 |
H-3 |
High |
Stored-XSS-prone user-build/profile URL fields (no scheme allowlist) |
#402 |
H-4 |
High |
Unauthenticated paid-LLM generation endpoints (VerseCraft) → cost exhaustion |
— |
H-5 |
High |
Rate limiter is process-local, ×4-inflated, and its client IP is spoofable |
— |
M-1 |
Medium |
Realtime socket handlers trust client-supplied |
#404, #405 |
M-2 |
Medium |
SSRF: |
#407 |
M-3 |
Medium |
Account pre-hijacking: no email verification / reset transport / linking policy |
#408 |
M-4 |
Medium |
Self-reported ranked results + unauthenticated Discord game-state writes |
— |
M-5 |
Medium |
No global request body-size cap (SSR memory-exhaustion DoS) |
— |
M-6 |
Medium |
rmhcode CLI tokens stored/looked up in plaintext |
— |
M-7 |
Medium |
MinIO default credential fallback; ledger Go service unauth writes (latent) |
— |
M-8 |
Medium |
CSP |
— |
L-1 |
Low |
Client-authoritative game scores (incl. unbounded |
— |
L-2 |
Low |
|
#409 |
L-3 |
Low |
Assorted hardening: constant-time webhook HMAC, Permissions-Policy, CI perms, etc. |
— |
Detailed findings¶
H-1 — Systemic economy check-then-write races enable coin minting¶
Severity: High · Confidence: High (static); requires live concurrency to confirm exploit reliability
lib/prisma.server.ts creates the client with no isolation-level override (Postgres default
READ COMMITTED), and no code uses SELECT … FOR UPDATE, row locks, or serializable
transactions. Balance/claim guards are implemented as read → compare in application code → write,
even when wrapped in $transaction. Under READ COMMITTED a plain SELECT takes no lock, so
concurrent requests observe the same pre-write state — every such guard is a genuine TOCTOU. The
coins column is a plain Int with no CHECK (coins >= 0) backstop.
Confirmed instances (fire N concurrent requests to trigger):
Battle-pass claim (High):
app/routes/api/battlepass/claim.ts:42-75. The “already claimed” guard is anincludes()on a JSON array with no unique constraint, no transaction, and no rate limiter at all. ConcurrentPOST /api/battlepass/claim {tier,track}all pass the check and allincrementthe reward — credited N times per tier, repeatable across tiers 1–100.Quest claim (High):
lib/quests/engine.server.ts:110-131(routeapi/quests/$id/claim.ts).claimedflag read, checked, and written in separate non-transactional statements → duplicate coin grants per completed quest.Balance transfer/spend overdraft → minting (High): the check-then-
decrementpattern inapp/routes/api/coins/tip.ts:48-61,lib/gifting/gift.server.ts:54-62,lib/storefront/storefront.server.ts:32-53,app/routes/api/shop/purchase.ts:49-57,lib/staking/staking.server.ts:34-49,app/routes/api/battlepass/unlock.ts:32-40,app/routes/api/coins/purchase.ts:47-69. Cleanest mint = tip: with balance 100, fire 5 concurrenttip {recipientId: alt, amount: 100}; each reads 100, each passes100 < 100 == false, each atomically decrements → sender goes to −400, the attacker’s alt receives +500. 400 coins created from nothing and laundered into a clean account. The atomicdecrementnever rejects the overdraft; the raced JS guard was the only defense.Prediction trade (High) — filed as #403:
lib/predictions/predictions.server.ts:110-126.upsert({ where:{userId}, update:{} })(empty update → no lock) then writes an absolutenewBalance = profile.coins - amount. Concurrent trades all deduct once but each records its own position; on YES resolution each winning share pays out — minting from a single stake.Plinko bet (Medium):
app/routes/api/coins/bet.ts:60-68writes an absolute balance (the only absolute-write mutation in the codebase) → lost-update against any concurrentincrement.lib/coins-schema.ts:3-6betSchema.amountalso has nomax(every other economy endpoint caps amount). (The Plinko outcome itself is fair — serverMath.random, house-negative EV.)Achievement reward (Medium):
lib/achievements/engine.server.ts:43-64— theunlockedAtcheck gatingrewardUnlockis a non-locking read; concurrent first-unlock triggers pay twice.Streak check-in (Low/Med):
lib/streak.server.ts:70-127— guard read outside the transaction, no@@unique([userId, dateKey]).Free coin claim (Low):
app/routes/api/coins/claim.ts:32-49— non-lockingcoins >= 10guard.Doctrine recruitment (Low, reputation not coins):
app/routes/api/doctrine/recruitment/redeem.ts:40-71.
Impact: Direct, reliable virtual-currency inflation and laundering; economy integrity and any redeemable/competitive value are broken.
Remediation: Move each invariant into the database. For “claim once”, use a child row with a
@@unique key and insert-then-grant in one $transaction (rely on P2002 — the DailyWheelSpin
and PromoClaim code already do this correctly). For “spend within balance”, replace every
read-then-decrement with a single conditional atomic update:
updateMany({ where:{ userId, coins:{ gte: amount } }, data:{ coins:{ decrement: amount } } }) and
require count === 1; add a CHECK (coins >= 0) constraint as a backstop. Never write absolute
balances. Add rate limiters to the currently-unthrottled claim routes. Retroactively audit ledgers
for negative balances / over-claims before deploying.
H-2 — Post translate and thread summary endpoints leak private & paywalled content¶
Severity: High · Confidence: High · Issues: #401, #406
Two AI endpoints read post content with no viewer authorization — only a “feature configured” check and an IP rate limit:
app/routes/api/rmharks/$id/translate.ts:38—findUnique({ where:{ id }, select:{ content } })with nogetSession,canViewPost(), orisLocked(). The canonical detail routermharks/$id.tsenforces exactly these gates. The per-(post,lang)cache (:7,32) has no viewer dimension, so once fetched, restricted content is served to everyone.app/routes/api/rmharks/$id/summary.ts:33-53— same class; the>= 3comment branch is easily met for paywalled posts since non-purchasers can still comment.
Exploit: With any post id (share link, mention, feed teaser, enumeration) for a PRIVATE or coins-paywalled post, an unauthenticated caller gets the translated / summarized substance — defeating both privacy and the coin paywall with no unlock recorded.
Remediation: Resolve the session and route both endpoints through the same
canViewPost + isLocked gates as rmharks/$id.ts before fetching content; key the translation
cache by viewer visibility.
H-3 — Stored-XSS-prone user-build & profile URL fields (no scheme allowlist)¶
Severity: High · Confidence: High · Issue: #402
lib/user-builds-schema.ts:14 — const urlSchema = z.string().url().max(500)… is used for
repoUrl and demoUrl. zod’s .url() (empirically confirmed on zod 4.4.3) accepts
javascript:, data:, and vbscript: schemes. The sibling imageUrlSchema (:15-18) does
enforce http(s)/relative — so the scheme gap here is an inconsistency, not a platform limitation.
The values render straight into anchor hrefs (BuildDetail.tsx:230,241, BuildCard.tsx:130,142)
with no safeHref helper anywhere in the tree. The same weak pattern affects profile website
(lib/profile-schema.ts:33-39, rendered at ProfileColumn.tsx:668-677 and served raw via the
public developer API api/v1/users/$handle.ts:39).
Exploitability nuance (verified): React 19.2.7 neutralizes javascript: hrefs at render
(rewrites to an inert throwing stub) for all case/whitespace variants, but does not neutralize
data:/vbscript: hrefs. The enforced CSP still contains script-src 'unsafe-inline', so it
provides no backstop. Thus the in-app javascript: vector is currently blunted by the framework,
but (a) data:/vbscript: and downstream non-React consumers of the public API are unprotected,
and (b) relying on React’s implicit scrubbing is fragile across versions/build modes. Issue #402
was verified real by 3/3 adversarial reviewers; the validation defect is unambiguous.
Remediation: Constrain the scheme at validation — replace .url() with a shared httpUrl()
helper: z.string().url().max(500).refine(u => /^https?:\/\//i.test(u), 'Only http(s) URLs') — and
add a safeHref() render helper that returns # unless the parsed protocol is http(s). Apply to
repoUrl, demoUrl, website, profileSong*Url, and chat-embed URLs.
H-4 — Unauthenticated paid-LLM generation endpoints (VerseCraft)¶
Severity: High · Confidence: High
lib/versecraft/gen/generate.server.ts:52-64 calls DeepSeek (api.deepseek.com/v1) with the
server key. Three routes invoke it with no session check, gated only by the process-local,
spoofable IP limiter (see H-5):
app/routes/api/versecraft/chapter.ts:18-23— 2 LLM calls/request, limit 40/min.app/routes/api/versecraft/outline.ts:17-22— limit 20/min.app/routes/api/versecraft/world.ts:63-71— reads the session but runs regardless (“anonymous is fine”).
This is the same class as the prior audit’s SEC-03 (public paid-AI abuse), but these routes were
never enumerated there and did not receive SEC-03’s remediation (distributed quota +
fail-closed-without-Redis) that api/vibe/ai.ts did get.
Exploit: An unauthenticated attacker rotates X-Forwarded-For/CF-Connecting-IP and varies
seed/index to defeat the cache, driving unbounded model spend; per-process counters reset on every
deploy/restart.
Remediation: Require a session (or signed short-lived capability) before any generation and
extend the vibe/ai distributed per-user + global-daily quota (fail-closed without Redis) to these
routes. Derive client IP only from a verified Cloudflare header.
H-5 — Rate limiter is process-local, ×4-inflated, and keyed on a spoofable client IP¶
Severity: High · Confidence: High
lib/rate-limit.ts is the site-wide throttle. Three compounding weaknesses:
Process-local (
:16-17): state is a plain in-processMap. A distributed limiter exists (redisRateLimit) but only ~2 call sites use it; every otherrateLimit()caller is per-instance and resets on every blue/green hotswap and restart.Hidden ×4 multiplier (
:45-49,66): every declared limit is silently multiplied byRATE_LIMIT_MULTIPLIER(default 4, up to 20).limit: 5is really 20/min. Auditors reading call sites under-count the true ceiling 4×.Spoofable client IP (
:99-115):getClientIpreturnscf-connecting-ipwith zero validation, then falls back toX-Forwarded-For. The origin Apache vhost (deploy/apache/rmhstudios.conf) does not usemod_remoteip, does not strip inboundCF-Connecting-IP/X-Forwarded-For, and has no Cloudflare-rangeRequire ipallowlist. If the origin IP is reachable directly (common via origin-IP leakage), an attacker sends a fresh randomCf-Connecting-IPper request → every request lands in a new bucket → the per-IP limiter never triggers.
Impact: Rate limiting is largely bypassable on every IP-keyed endpoint (tips, messages, votes, score submits, AI, uploads), directly amplifying H-1, H-2, H-4, and the score-forgery issues.
Remediation: At the edge, restrict origin to Cloudflare ranges and re-derive the client IP via
mod_remoteip, stripping client-supplied forwarding headers. In app code, only trust
cf-connecting-ip on connections verified from Cloudflare, key authenticated endpoints by
session.user.id, route rateLimit() through Redis with the in-memory map as fallback, and remove
or default-to-1 the hidden multiplier so the code is the source of truth.
M-1 — Realtime socket handlers trust client-supplied userId¶
Severity: Medium · Confidence: High · Issues: #404, #405
The socket server uses soft auth (server/socket-server/index.ts:64-107) — tokenless connections
are allowed and only populate socket.data.userId when a valid session token is presented. Two
handlers derive the acting identity from the client payload instead:
server/socket-server/handlers/rmhstudy.ts:554-556,632-634— preferspayload.userIdoversocket.data.userId;persistWorkSessionthen writes cumulative study stats (focus time, sessions, streaks) to any user’s profile. Victim ids are broadcast to all room members.server/socket-server/handlers/synapse-storm.ts:155,211,289,301,377,440,504— reads onlypayload.userId, never the session; both identity andscoreare client-controlled, forging match-history rows under any user id (bounded toss_match/ss_player_match, which no public leaderboard reads — see scope note in #405).
Remediation: On a soft-auth server, never trust payload.userId for writes. Require an
authenticated session for any persisting event and use socket.data.userId exclusively; reject any
payload.userId that doesn’t equal it. Sweep the other soft-auth handlers for the same pattern.
M-2 — SSRF: rebinding TOCTOU, guard bypasses, and an open image proxy¶
Severity: Medium · Confidence: High · Issue: #407
Rebinding TOCTOU (#407):
lib/ssrf-guard.server.tsvalidates DNS inassertSafeUrl(:111,lookup(..., {all:true})) butsafeFetchthen callsfetch(current)(:147), and undici performs its own DNS resolution at connect time. The validated IP is never pinned, so a low-TTL / alternating record passes validation as a public IP and connects to127.0.0.1/169.254.169.254. Reachable, unauthenticated, noallowedHosts, and reflecting:api/oembed.ts(type=ogreturns parsed page metadata) andapi/image-proxy.ts(returns fetched bytes).Guard bypass:
lib/homes/scrape/http.ts:23-41politeFetchuses barefetchwithredirect: 'follow'and no private-IP check — unlike the RMHLadder adapters and outbound webhooks which correctly usesafeFetch. Source URLs are seed-controlled today (not directly attacker-supplied), so this is defense-in-depth, but a redirecting/compromised feed host reaches internal services unguarded.Open image proxy:
api/image-proxy.tshas norateLimitand no auth (unlikeapi/oembed.ts) — bandwidth-amplification/DoS and the prime delivery surface for the rebinding TOCTOU.IPv6 gap:
isPrivateIp(ssrf-guard.server.ts:65) only catches literalfe80of thefe80::/10link-local range and omits198.18.0.0/15/ NAT64 — the RMHLadder guard (rmhladder/adapters/http.ts:118) is stricter; unify on it.
Remediation: Resolve once, validate, and pin the connection to that exact IP via a custom
undici Agent/dispatcher lookup, re-pinning per redirect hop. Route homes/politeFetch through
safeFetch. Add auth/rate limiting to image-proxy. Unify isPrivateIp on the stricter ranges.
M-3 — Account pre-hijacking: no email verification, reset transport, or linking policy¶
Severity: Medium · Confidence: Medium-High · Issue: #408
lib/auth.ts:65-67 enables emailAndPassword with no requireEmailVerification, and a repo-wide
search finds no sendVerificationEmail / sendResetPassword / accountLinking configuration. All
three social providers set overrideUserInfoOnSignIn: true (:52,57,62).
Consequences: (1) email ownership is never proven for credential accounts, enabling classic
pre-hijacking — an attacker pre-registers victim@example.com; when the victim later signs in
via Google/GitHub, Better Auth’s default linking merges the identities onto the attacker’s existing
credential record, leaving the attacker persistent password access. (2) Self-service password reset
appears inoperable without a sendResetPassword transport, despite the /forget-password rate rule
at :44.
Remediation: Configure requireEmailVerification, wire real sendVerificationEmail /
sendResetPassword transports, and set account.accountLinking to link only verified-email
providers (never auto-link onto an unverified pre-existing account).
M-4 — Self-reported ranked results + unauthenticated Discord game-state writes¶
Severity: Medium · Confidence: High
Ranked challenge (
app/routes/api/ranked/challenge/$id.ts:54-74): either participant mayPOST {action:'report', result:'win'}and the first report wins — no opponent confirmation, no anti-collusion, and thestatus !== 'accepted'guard races the update. A loser can report themselves the winner (ELO/W-L fraud; no coins).Discord game state (
app/routes/api/discord/daily-progress.ts,discord/race.ts): both read and write per-user records keyed by adiscordIdtaken straight from the request with no authentication. The correct pattern exists next door —discord/sync-score.ts:40-58verifies the Discord OAuth token before mappingdiscordId → userId. Discord ids are public, enumerable snowflakes, so anyone can read or overwrite another user’s puzzle/lobby state (IDOR, low data sensitivity).
Remediation: Require both participants to report ranked results (or use server-authoritative
match results) with an atomic conditional status transition. Verify the Discord access token (as
sync-score does) and derive discordId from the verified identity for all Discord routes.
M-5 — No global request body-size cap (SSR memory-exhaustion DoS)¶
Severity: Medium · Confidence: High
There is no Nitro/Apache/Vite body cap (no LimitRequestBody/client_max_body_size/bodyLimit).
A correct streaming guard exists — lib/http-body.server.ts readRequestBodyLimited (enforces
during read, chunked-safe) — but only ~4 RMHLadder routes use it. ~150 routes call
await request.json() with no ceiling, buffering the whole body in RAM. The handful that add a
manual content-length check (e.g. signal-forge/save.ts:21, forest-explorer/save.ts:57) read
the client-supplied header only, so a Transfer-Encoding: chunked body bypasses them and still
buffers unbounded. The library upload (app/routes/api/library/upload.ts:67) likewise buffers the
whole file before the size check in processLibraryUpload.
Exploit: A few concurrent POST requests with large chunked bodies exhaust web-tier memory
(single blue/green instance); amplified by the H-5 rate-limit bypass.
Remediation: Enforce a global body cap at the Nitro/Apache layer and replace ad-hoc
content-length checks with readRequestBodyLimited throughout (including the library upload).
M-6 — rmhcode CLI tokens stored and looked up in plaintext¶
Severity: Medium · Confidence: High
Unlike developer API keys (SHA-256 hashed at rest), CLI tokens are persisted and queried as raw
secrets: cli/... rmhcode/auth/generate.ts:58-68 stores token verbatim;
rmhcode/auth/validate.ts:45,80-89 does findUnique({ where:{ token } }) and returns the user’s
email. Any read of the rmhCodeToken table (backup/replica exposure, log leakage, insider, or an
injection defect elsewhere) yields live bearer tokens with no cracking required.
Remediation: Store only sha256(token) and look up by hash (mirror
lib/api/developer-auth.server.ts); show the plaintext once at creation. (Ownership on
revoke/list is already correctly enforced.)
M-7 — MinIO default credential fallback; ledger Go service unauthenticated writes (latent)¶
Severity: Medium · Confidence: High
MinIO default creds:
docker-compose.yml:310-311,333fall back toMINIO_ROOT_USER=…:-rmhminio/MINIO_ROOT_PASSWORD=…:-rmhminio-dev-secret, and.env.example:309ships the same value. IfS3_*are unset in prod (or.env.exampleis copied verbatim), MinIO runs with publicly-known root credentials. Mitigated by loopback binding (:313); prod is intended to use Cloudflare R2, so confirm the compose MinIO isn’t the production store.Ledger Go service (latent):
go-services/cmd/ledger/main.go+internal/ledger/handler.go:25-31registerPOST /ledger/v0/artifacts|runs|stepswith no auth and no rate limiting, binding:7100on all interfaces. Pergo-services/CLAUDE.mdit is implemented but not integrated/deployed, so this is a finding to fix before it ships. The Go WSoriginChecker(pkg/realtime/hub.go:81-90) similarly defaults to allow-all when the allowlist is empty.
Remediation: Drop the working MinIO default (fail closed if S3_SECRET_ACCESS_KEY unset in prod);
gate the compose minio service behind a dev profile. Before integrating ledger, add
auth + loopback binding + rate limiting, and make the Go WS origin check fail closed in production.
M-8 — CSP unsafe-inline retained; Apache/Traefik configs diverge (latent)¶
Severity: Medium · Confidence: High
The production CSP is now correctly enforced (not report-only) and unsafe-eval is removed —
both SEC-05 improvements hold (deploy/apache/rmhstudios.conf:54). Residual gaps:
script-srcstill contains'unsafe-inline', so an injection defect could still execute inline scripts (relevant to H-3’sdata:/vbscript:residue and any future sink).object-src 'none'andbase-uri 'none'are correctly set.The Traefik middleware (
deploy/helm/.../security-headers-middleware.yaml) does not match Apache: it emits a nativeframe-ancestors-only CSP plus a full policy mislabeled under the enforcingContent-Security-Policyheader while commented “Report-Only”. CLAUDE.md §8 requires both to change together. Not on the production request path today (Apache is), so this bites at the k3s cutover.
Remediation: Move to a nonce/hash-based script-src (drop unsafe-inline); reconcile the
Traefik middleware to a single enforced policy identical to Apache (with a correctly-named
-Report-Only header if a tuning policy is wanted). Add a Permissions-Policy header (absent on all
paths).
L-2 — /api/v1/posts/{id}/comments audience IDOR¶
Severity: Low · Confidence: High · Issue: #409
app/routes/api/v1/posts/$id/comments.ts:28-33 (GET) never loads the parent post’s
audience/userId, unlike sibling $id.ts which 404s on non-public/non-owned posts. A developer
key with the low-privilege read:feed scope can read the full comment thread and each commenter’s
id/name/handle/image on a FOLLOWERS-only post.
Remediation: Load the parent post and mirror the $id.ts audience gate before returning comments.
L-3 — Assorted hardening¶
Non-constant-time webhook HMAC:
webhook-server.cjs:41compares the signature with!==instead ofcrypto.timingSafeEqual(the project’s owntimingSafeStringEqualexists inlib/internal-auth.ts). Practically hard to exploit; the server otherwise fails closed withoutWEBHOOK_SECRETand doesn’t log the expected HMAC. Confirmed no command-injection in the webhook→deploy path (fixedBRANCH_ENV_MAP, argvspawn).CI least-privilege:
deploy.yml,web-ci.yml,go-microservices.ymllack a top-levelpermissions:block (inherit the repo default token scope). SSH deploy trusts host key on first use whenDEPLOY_KNOWN_HOSTSis unset (deploy.yml:55-73).senior-review.ymlruns an LLM withBashonpull_requestgated only by author login (acceptable — nopull_request_target, fork PRs get no secrets). All actions are SHA-pinned (good).Container/image hygiene: third-party images pinned by tag not digest; no
mem_limit/cpusin compose (onlypids_limit).discord/token.ts:54-79returns the raw Discord access token to the browser (Low — the caller’s own token, but unnecessary exposure).api/admin/curated-builds/image/$filename.tsserves files with no auth (path-traversal-guarded, public images only — namespace-misleading, Low).Dead dependency:
markedis inpackage.jsonbut imported nowhere — remove to shrink surface.Seed script shell interpolation:
scripts/seed-news-from-git.ts:68usesexecSyncwith a template string (inputs are constants — not exploitable; preferexecFileSync).
Positive controls observed (verified, not assumed)¶
#400 remediated:
isAdmin/isVerifiedare Better Authinput: falsefields (lib/auth.ts:83,89);customSessionre-resolves them from the DB on everygetSession(no cookie cache), so admin/ban revocation is immediate. No privileged fields inupdateProfileSchema.Admin gating is consistent across all 34
api/admin/*+api/doctrine/admin/*routes (session →isAdmin), with self-demotion/other-admin protections and audit logging.No IDOR in the core app: every owner-scoped mutation re-verifies ownership against
session.user.id(keys, scheduled posts, messages, group chats, rideshare, notifications, storefront). Account delete/export are own-scoped, transactional, exclude secrets.Developer v1 API is uniformly hardened:
withDeveloperApienforces per-endpoint scopes, idempotency on writes, tiered distributed limits, and re-resolves tier/ban every request; keys are SHA-256-hashed.Internal service auth fails closed and is constant-time (
lib/internal-auth.ts).Stripe webhooks are signature-verified via the official
@better-auth/stripeplugin (STRIPE_WEBHOOK_SECRET); membership tier is server-derived (getUserTier), never client-trusted. Wheel/promo/referral claims use correct atomic unique-constraint patterns.Injection-clean: all raw SQL (
$queryRawUnsafe/tagged templates) is parameterized; no user-controlledos/exec/spawnargs in Node, scripts, or Go; path-traversal guards (isSafeLibraryId,resolvePathUnder, S3 key allowlists) are correct.Uploads validated by magic bytes (SVG rejected → no stored-SVG XSS); stored content-type is server-derived; EPUB parsing is client-side with
allowScriptedContent: false; DOCX has a decompression-bomb guard;sharpkeeps its default pixel cap.XSS surface small and controlled: two
dangerouslySetInnerHTMLsites (one static, one strict-allowlistsanitize-html); markdown viareact-markdownwith norehype-raw; generated vibe pages run in a sandboxed opaque-origin iframe with noallow-same-origin;jsonLdScriptescapes<.Secrets: no committed secrets (only placeholder
.env.example*); noVITE_/import.meta.envsecret leakage; the.serverboundary is intact (only type-only imports from client code).Infra: CSP enforced with no
unsafe-eval; non-root containers withread_only+cap_drop: ALL+no-new-privileges+pids_limit+ tmpfs; all published ports loopback-bound; the supervisor no longer mounts the repo or docker socket; source maps disabled; Go auth/session validation and the gatewayX-Rmh-*header-stripping trust boundary are sound.
Remediation roadmap¶
0–7 days (stop active fraud & data leakage)¶
H-1 — convert every economy claim/spend to atomic conditional updates + unique constraints; add
CHECK (coins >= 0); add the missing rate limiters (battle-pass first). Audit for existing negative balances / over-claims.H-2 / L-2 — add session +
canViewPost+isLockedgates totranslate,summary, and the v1 comments route; key AI caches by viewer.H-3 — add an
http(s)-only scheme check torepoUrl/demoUrl/website/song URLs and asafeHref()render helper.H-4 — gate the three VerseCraft routes behind a session + the distributed AI quota.
H-5 — restrict the origin to Cloudflare ranges +
mod_remoteip, strip client forwarding headers, and stop trustingcf-connecting-ipunconditionally.
8–30 days¶
M-1 — derive socket identity from
socket.data.userIdonly; sweep all soft-auth handlers.M-2 — pin the validated IP in
safeFetch; route homes scraper through it; auth/limitimage-proxy.M-3 — email verification + reset transport + explicit account-linking policy.
M-4 / M-5 / M-6 — two-sided ranked reporting + Discord token verification; global body cap +
readRequestBodyLimitedeverywhere; hash CLI tokens.M-7 / M-8 — remove MinIO default creds; harden the
ledgerservice before integration; drop CSPunsafe-inline(nonces) and reconcile the Traefik middleware; addPermissions-Policy.
31–90 days¶
Server-authoritative or replay-verifiable scoring (L-1); route-inventory static test that default-denies unwrapped mutations.
Centralized security telemetry: auth anomalies, admin actions, score/economy anomalies, model spend, quota-bypass attempts, CSP reports.
Authenticated external penetration test focused on the concurrency races, DNS-rebinding SSRF, OAuth/passkey linking, Stripe webhook replay, developer-API scopes, uploads, and WebSocket authZ.
Add SBOMs, image-digest pinning, signed provenance, and formal vulnerability SLAs; make
DEPLOY_KNOWN_HOSTSmandatory and add least-privilegepermissions:blocks to all workflows.
Scope limitations & required follow-up evidence¶
The following were not available or were intentionally excluded and must not be inferred as secure:
Deployed Cloudflare WAF/rate-limit/bot rules, origin firewall, and actual response headers (H-5 and M-8 severity partly depend on whether the origin rejects non-Cloudflare traffic).
Live confirmation of the concurrency races (H-1, M-4) under real database timing/isolation, and of the DNS-rebinding TOCTOU (M-2) with a controlled malicious-DNS harness.
Production/staging secret strength, age, rotation, and storage;
.env*values were not inspected.GitHub branch protection, required checks, environment approvals, and Dependabot settings.
Database row-level permissions, backup encryption/restoration, and audit retention.
Go/Bazel third-party advisory scanning and container/OS package CVE scanning.
This report is a code/configuration review, not a penetration test and not a SOC 2 / ISO 27001 / PCI DSS certification.