Skip to content

fix(security): address critical/high/medium audit findings on the write & auth paths#305

Open
ety001 wants to merge 4 commits into
nextfrom
security/audit-fixes
Open

fix(security): address critical/high/medium audit findings on the write & auth paths#305
ety001 wants to merge 4 commits into
nextfrom
security/audit-fixes

Conversation

@ety001

@ety001 ety001 commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the security audit findings on the next branch (3 Critical, 4 High, multiple Medium/Low). All changes verified: type-check clean, lint clean, 424 tests pass, production build succeeds.

Split into three commits by severity for review.

P0 — broadcast integrity (Critical)

  • recover-account: return 503 when MySQL is unavailable. Previously the recovery-record cross-check (status === 'closed', key match) was wrapped in if (db) and skipped on DB-down — broadcasting an account-takeover op with no application-layer gate. Now fail-closed, matching recovery/request and recovery/confirm.
  • Enforce operation type on every broadcast route. Only account-update validated operations[0][0]; the other 15 routes relayed any signed op verbatim (e.g. a transfer could be posted to /vote to use its higher rate-limit budget). New assertSignedTxOpType() applied to all routes.
  • Rename verifySignature()validateTransactionShape() with an honest security note: it validates shape only, not signatures (the chain rejects bad sigs on broadcast). Real crypto verification is tracked separately — it needs serializeTransaction exported upstream (@steemit/steem-js); the library's built-in verifyTransaction is broken (verifies against JSON.stringify instead of the binary digest).

P1 — hardening (High)

  • CSRF: replace forgeable base64(secret:ts:rand) token with HMAC-signed token (base64url(ts).base64url(HMAC(ts))), constant-time comparison, no insecure default secret. Production rejects all mutations if CSRF_SECRET is unset.
  • Login: reject (503) when Redis is unavailable, instead of authenticating on a public-key match alone.
  • Rate limiting: proxy-aware client IP via TRUST_PROXY_COUNT (no longer trusts spoofable X-Forwarded-For blindly), per-route scoped keys, optional fail-closed (RATE_LIMIT_ALLOW_MEMORY_FALLBACK=false), fixed the hardcoded config_maxRequests.
  • Security headers: X-Frame-Options: DENY, nosniff, Referrer-Policy, Permissions-Policy, HSTS, frame-ancestors 'none'.

P2 — info leakage, entropy, XSS, misc (Medium/Low)

  • custom_json: allowlist op-ids (community/follow/reblog) + payload validation.
  • Analytics: require CSRF (client mirrors token).
  • accounts query: hash full param for cache key (fixes prefix-truncation collision).
  • recovery/confirm: preflight CONVEYOR_POSTING_WIF config → clean 503.
  • recovery/verify: tighten rate limit 20→10/300s.
  • help-markdown: parse inline HTML then sanitize via rehype-sanitize.
  • redis: reconnection cooldown to avoid retry storms.
  • X-Cache-Invalidate: validate Steem-name format before reflecting into header.
  • Replace Math.random() with crypto for challenge/community entropy.
  • healthcheck: stop leaking docker_tag/source_commit.
  • drizzle.config: require DATABASE_URL, drop hardcoded root:12345678.
  • Strip internal error details from broadcast 500s (still logged server-side).

Test plan

  • pnpm type-check — clean
  • pnpm lint — 0 errors
  • pnpm test — 424 passed (includes new tests for the DB gate + op-type helper)
  • pnpm build — succeeds

Notes for reviewers

  • Required deploy config: set CSRF_SECRET (e.g. openssl rand -hex 32) and TRUST_PROXY_COUNT (proxy hop count). Without CSRF_SECRET, all mutations are rejected in production by design.
  • The real signature-verification follow-up (verifyTransaction upstream fix) is documented in a separate plan; this PR's op-type + CSRF + chain-rejection defense-in-depth removes the exploitable surface meanwhile.

ety001 added 4 commits July 10, 2026 10:46
… honest verifySignature

Three critical audit findings on the write/broadcast path:

1. recover-account: return 503 when MySQL is unavailable (was: skip the
   recovery-record cross-check and broadcast anyway). This is the only
   application-layer gate on the account-takeover path; fail-closed to match
   recovery/request and recovery/confirm.

2. Enforce the operation type on every broadcast route. Previously only
   account-update validated operations[0][0]; the other 15 routes relayed any
   signed operation verbatim, so a transfer could be posted to /vote to use its
   higher rate-limit budget. New assertSignedTxOpType() helper applied to all
   routes.

3. Rename the misleading verifySignature() to validateTransactionShape() with a
   security note: it checks transaction shape only, not signatures — the chain
   rejects bad signatures on broadcast. Real crypto verification is tracked
   separately (needs serializeTransaction exported upstream).

Adds tests for the DB-unavailable→503 gate and the op-type helper.
…e rate limit, headers

1. CSRF: replace the forgeable base64(secret:ts:rand) token with an
   HMAC-signed token (base64url(ts).base64url(HMAC(ts))), constant-time
   comparison, and no insecure default secret. Production rejects all
   mutations if CSRF_SECRET is unset; docker-compose now requires it.

2. Login: reject (503) when Redis is unavailable instead of authenticating on a
   public-key match alone — the challenge signature check needs Redis.

3. Rate limiting: proxy-aware client IP via TRUST_PROXY_COUNT (no longer trusts
   spoofable X-Forwarded-For blindly), per-route scoped keys (the /vote budget
   no longer shares /transfer's), optional fail-closed when
   RATE_LIMIT_ALLOW_MEMORY_FALLBACK=false, and fix the hardcoded
   config_maxRequests in getRateLimitInfo.

4. Add security response headers: X-Frame-Options DENY, nosniff, Referrer-Policy,
   Permissions-Policy, HSTS, and CSP frame-ancestors 'none'.
… hardening

- custom_json: allowlist op-ids (community/follow/reblog), validate id format
  and payload length; stop being an open arbitrary-payload relay.
- analytics: require CSRF (client now mirrors the token); prevent log injection.
- accounts query: hash the full names param for the cache key (was: truncated to
  200 chars, causing distinct lists to collide / cross-user cache poisoning).
- recovery/confirm: preflight CONVEYOR_POSTING_WIF config (high-value secret) —
  return clean 503 when missing/misconfigured instead of 500.
- recovery/verify: tighten rate limit 20→10/300s (account_name is an enumeration
  surface, mitigated by 80-bit admin codes).
- help-markdown: parse inline HTML then sanitize via rehype-sanitize (was:
  rehype-raw with no sanitization → stored XSS if a source file is tampered).
- browser-storage: document the localStorage posting-key risk and mitigations.
- redis: gate reconnection with a cooldown to avoid a retry storm on flapping.
- cache-invalidate header helper: validate Steem-name format before reflecting
  username into the header (header-injection hardening).
- client.ts/server/community: replace Math.random() with crypto for challenge
  and community-name entropy.
- healthcheck: stop leaking docker_tag/source_commit to anonymous callers.
- drizzle.config: require DATABASE_URL, drop hardcoded root:12345678 fallback.
- strip internal error details from broadcast 500 responses (still logged server-side).
…mment order

Self-review of PR #305:
- community.ts generateCommunityOwnerName: when crypto.getRandomValues is
  unavailable it silently produced a deterministic 'hive-100000' (collision
  risk). Now throws like cryptoRandomHex does, instead of falling back.
- vote/transfer routes: move the 'Broadcast the transaction' comment below the
  op-type guard so the comment matches the code that follows it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant