fix: audit-2026-05-13 frontend hardening (CSRF, slippage caps, advisories)#80
Conversation
CSRF + rate-limit on coinblast write paths - /api/pin: add origin allowlist + 10 pins/hour per-IP limit; same pattern as faucet's /api/faucet. Defends Pinata quota / billing. - /api/cb POST: same origin gate. GET stays open (read-only). - Drop image/svg+xml from pin MIME allowlist — XSS risk if any future component renders via <object>/<iframe>/srcdoc. - Route OG-image rewrite through ipfsToGateway() so NEXT_PUBLIC_IPFS_GATEWAY override actually applies. Slippage policy - coinblast: cap user input at 10% (was 15%); add expert-mode confirmation modal for any swap > 5%. - dex: cap at 5% with explicit warning ≥3%; add custom slippage input to SlippageControl. Advisory churn - Bump next 16.2.3 → 16.2.6 on solux + chain-landing (closes 11 HIGH middleware/proxy advisories on next 16). - Add pnpm.overrides axios ^1.15.2 — pulls 1.15.2+ for the wagmi → @base-org/account → @coinbase/cdp-sdk transitive path. Closes 10 axios advisories incl. CVE-2025-62718 (NO_PROXY SSRF) + GHSA-q8qp-cvcw-x6jj (prototype-pollution gadgets). - pnpm audit went from 29 vulns (11 high) to 1 moderate (postcss inside next 15.5.18, awaiting upstream). Defence-in-depth - faucet getClientIP: in production require CF-Connecting-IP; reject 503 if missing instead of silently falling through to spoofable XFF. - solux Settings: clear clipboard on component unmount (the existing 60s setTimeout doesn't fire if tab is backgrounded), and toast copy of secrets with explicit "clear manually" guidance. - airdrop /proofs.json: append ?v=BUILD_ID query so cache busts on schema rotation. Misc - coinblast cb-deploy-7-tokens.mjs: fix nativeCurrency.name 'SRX' → 'Sentrix' per chain naming canonical. - Root .gitignore: add .env.production / .env.production.local. - apps/solux/.gitignore: drop the !.env.production allow-list (was the opposite of the intended deny). Mirror faucet's env stanza.
📝 WalkthroughWalkthroughThis PR is a substantial multi-area hardening and feature release. It adds security infrastructure (origin allowlisting and per-IP rate limiting) to Coinblast API routes, introduces a complete token deployment automation script that pins icons to IPFS and registers owner-signed metadata, tightens slippage risk controls across trading widgets with configurable thresholds and confirmation modals, refactors metadata resolution to use shared IPFS helpers, implements deterministic clipboard clearing in account settings, hardens IP validation in the production faucet, and updates environment ignores and Next.js/axios versions. The changes span infrastructure security, deployment automation, user-facing risk controls, and dependency management. Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
apps/coinblast/src/lib/rateLimit.ts (1)
59-70: ⚡ Quick winConsider requiring CF-Connecting-IP in production for consistency.
Unlike the faucet route's
getClientIP(which hard-requiresCF-Connecting-IPin production and returnsnullotherwise), this implementation always falls back to potentially spoofable headers (X-Real-IP,X-Forwarded-For).While the
checkOrigingate provides defense-in-depth, aligning this with the faucet's approach would strengthen the rate-limit enforcement: an attacker bypassing the edge proxy (e.g., by hitting the origin directly) could also spoofX-Forwarded-Forto bypass per-IP limits.🔒 Proposed change to require CF-Connecting-IP in production
export function getClientIP(req: Request): string { const cf = req.headers.get("cf-connecting-ip"); if (cf) return cf.trim(); + if (process.env.NODE_ENV === "production") { + // Missing CF-Connecting-IP in production = potential bypass. + // Treat as localhost (single bucket for all unverified traffic). + return "127.0.0.1"; + } const realIP = req.headers.get("x-real-ip"); if (realIP) return realIP.trim();Note: Returning
"127.0.0.1"lumps all unverified traffic into one rate-limit bucket rather than allowing per-spoofed-IP bypass.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/coinblast/src/lib/rateLimit.ts` around lines 59 - 70, getClientIP currently falls back to X-Real-IP/X-Forwarded-For, allowing spoofing; change it so that in production it requires CF-Connecting-IP only (do not fall back) and returns null (or the same sentinel used by the faucet route) when CF-Connecting-IP is missing, while in non-production keep the existing fallback behavior; update the getClientIP function and align its return sentinel with the faucet route's getClientIP so rate-limiting and checkOrigin defenses behave consistently.apps/coinblast/src/lib/origin.ts (1)
14-29: ⚡ Quick winDocument production-only origins in .env.example and ensure COINBLAST_ALLOWED_ORIGINS is properly configured.
The
DEFAULT_ALLOWEDincludes localhost origins (3000-3002) intended for development, butCOINBLAST_ALLOWED_ORIGINSis not documented in.env.example. While Caddy's TLS termination and routing in production means external browsers won't send localhost origins, it's a documentation gap and introduces risk if the reverse proxy is misconfigured. AddCOINBLAST_ALLOWED_ORIGINSto.env.examplewith production-only origins (e.g.,https://coinblast.sentriscloud.com), matching the approach used in the faucet app.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/coinblast/src/lib/origin.ts` around lines 14 - 29, Add a COINBLAST_ALLOWED_ORIGINS entry to .env.example documenting the production-only origins (e.g., "https://coinblast.sentriscloud.com") and instructions that this value overrides the DEFAULT_ALLOWED list used by the DEFAULT_ALLOWED constant and parsed into the ALLOWED_ORIGINS Set in apps/coinblast/src/lib/origin.ts; ensure the example emphasizes using only production origins (not localhost) and shows the comma-separated format expected by the COINBLAST_ALLOWED_ORIGINS environment variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/coinblast/cb-deploy-7-tokens.mjs`:
- Around line 233-244: Replace the single 8s sleep + best-effort postMetadata
call with a retry loop that polls/post-retries with exponential backoff: after
creating the token event, repeatedly call postMetadata(launchClient, launchAcct,
curveAddr, imageUri, t.desc) until it succeeds or a max total timeout/attempts
is reached (use increasing delays between attempts), log each attempt and error,
and if the max timeout/attempts is exceeded throw/fail the script so deployment
does not exit silently with missing metadata; update references around the
existing console messages and the try/catch block to implement this behavior for
each token.
In `@apps/solux/src/components/Settings.tsx`:
- Around line 54-61: In Settings.tsx update the two clipboard-clear timeout
callbacks to check the current guard before calling
navigator.clipboard.writeText: in the seed timeout callback verify
lastCopiedSecret.current === mnemonic and only then call writeText('') and clear
lastCopiedSecret.current, and likewise in the key timeout callback verify
lastCopiedSecret.current === privateKey before calling writeText('') and
clearing the ref; this prevents an earlier timer from wiping the clipboard after
a different secret was copied.
---
Nitpick comments:
In `@apps/coinblast/src/lib/origin.ts`:
- Around line 14-29: Add a COINBLAST_ALLOWED_ORIGINS entry to .env.example
documenting the production-only origins (e.g.,
"https://coinblast.sentriscloud.com") and instructions that this value overrides
the DEFAULT_ALLOWED list used by the DEFAULT_ALLOWED constant and parsed into
the ALLOWED_ORIGINS Set in apps/coinblast/src/lib/origin.ts; ensure the example
emphasizes using only production origins (not localhost) and shows the
comma-separated format expected by the COINBLAST_ALLOWED_ORIGINS environment
variable.
In `@apps/coinblast/src/lib/rateLimit.ts`:
- Around line 59-70: getClientIP currently falls back to
X-Real-IP/X-Forwarded-For, allowing spoofing; change it so that in production it
requires CF-Connecting-IP only (do not fall back) and returns null (or the same
sentinel used by the faucet route) when CF-Connecting-IP is missing, while in
non-production keep the existing fallback behavior; update the getClientIP
function and align its return sentinel with the faucet route's getClientIP so
rate-limiting and checkOrigin defenses behave consistently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: b1103b3b-13d8-4b5c-b682-0aebfc0967cd
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (16)
.gitignoreapps/airdrop/src/components/ClaimWidget.tsxapps/chain-landing/package.jsonapps/coinblast/cb-deploy-7-tokens.mjsapps/coinblast/src/app/api/cb/[...path]/route.tsapps/coinblast/src/app/api/pin/route.tsapps/coinblast/src/app/token/[address]/layout.tsxapps/coinblast/src/components/token/BuySellWidget.tsxapps/coinblast/src/lib/origin.tsapps/coinblast/src/lib/rateLimit.tsapps/dex/src/app/SwapWidget.tsxapps/faucet/src/app/api/faucet/route.tsapps/solux/.gitignoreapps/solux/package.jsonapps/solux/src/components/Settings.tsxpackage.json
| // Wait a few seconds for indexer to pick up the event before | ||
| // POSTing metadata (the indexer requires the row to exist first). | ||
| console.log('[meta] waiting 8s for indexer…') | ||
| await new Promise((r) => setTimeout(r, 8000)) | ||
|
|
||
| console.log('[meta] POST owner-signed metadata') | ||
| try { | ||
| const metaResp = await postMetadata(launchClient, launchAcct, curveAddr, imageUri, t.desc) | ||
| console.log('[meta] →', metaResp) | ||
| } catch (e) { | ||
| console.log('[meta] ⚠ failed (will need retry):', e.message) | ||
| } |
There was a problem hiding this comment.
Don’t make metadata publication a best-effort 8s race.
The script knows the indexer gate is eventual, but it waits once for 8 seconds and then swallows any failure. That makes rollout success timing-dependent and can leave tokens deployed without metadata while the script still exits cleanly. Retry with backoff until the row exists, and fail the run if a token never publishes.
Suggested patch
+async function postMetadataWithRetry(walletClient, account, curveAddress, imageUri, description) {
+ let delayMs = 2000
+ let lastError
+
+ for (let attempt = 1; attempt <= 6; attempt += 1) {
+ try {
+ return await postMetadata(walletClient, account, curveAddress, imageUri, description)
+ } catch (error) {
+ lastError = error
+ if (attempt === 6) break
+ console.log(`[meta] attempt ${attempt} failed, retrying in ${delayMs}ms: ${error.message}`)
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+ delayMs *= 2
+ }
+ }
+
+ throw lastError
+}
+
@@
- // Wait a few seconds for indexer to pick up the event before
- // POSTing metadata (the indexer requires the row to exist first).
- console.log('[meta] waiting 8s for indexer…')
- await new Promise((r) => setTimeout(r, 8000))
-
- console.log('[meta] POST owner-signed metadata')
- try {
- const metaResp = await postMetadata(launchClient, launchAcct, curveAddr, imageUri, t.desc)
- console.log('[meta] →', metaResp)
- } catch (e) {
- console.log('[meta] ⚠ failed (will need retry):', e.message)
- }
+ console.log('[meta] POST owner-signed metadata')
+ const metaResp = await postMetadataWithRetry(
+ launchClient,
+ launchAcct,
+ curveAddr,
+ imageUri,
+ t.desc,
+ )
+ console.log('[meta] →', metaResp)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/coinblast/cb-deploy-7-tokens.mjs` around lines 233 - 244, Replace the
single 8s sleep + best-effort postMetadata call with a retry loop that
polls/post-retries with exponential backoff: after creating the token event,
repeatedly call postMetadata(launchClient, launchAcct, curveAddr, imageUri,
t.desc) until it succeeds or a max total timeout/attempts is reached (use
increasing delays between attempts), log each attempt and error, and if the max
timeout/attempts is exceeded throw/fail the script so deployment does not exit
silently with missing metadata; update references around the existing console
messages and the try/catch block to implement this behavior for each token.
| useEffect(() => { | ||
| return () => { | ||
| if (lastCopiedSecret.current && navigator.clipboard) { | ||
| navigator.clipboard.writeText('').catch(() => {}); | ||
| lastCopiedSecret.current = null; | ||
| } | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -C2 "setTimeout\\(\\(\\) => \\{|writeText\\(''\\)|lastCopiedSecret\\.current ===|lastCopiedSecret\\.current !==" apps/solux/src/components/Settings.tsxRepository: Sentriscloud/frontend
Length of output: 967
Move guard checks before writeText() to prevent unrelated clipboard clears.
In both timeout callbacks, writeText('') executes before verifying the secret is still current. If a user copies a different secret before the 60-second timer fires, the original timer will unnecessarily clear the clipboard anyway. Reorder each check to guard the write operation:
- Lines 90–93 (seed timeout): Check
lastCopiedSecret.current === mnemonicbefore callingwriteText - Lines 153–156 (key timeout): Check
lastCopiedSecret.current === privateKeybefore callingwriteText
Proposed fix
setTimeout(() => {
- navigator.clipboard.writeText('').catch(() => {});
- if (lastCopiedSecret.current === mnemonic) lastCopiedSecret.current = null;
+ if (lastCopiedSecret.current !== mnemonic) return;
+ lastCopiedSecret.current = null;
+ navigator.clipboard.writeText('').catch(() => {});
}, 60_000); setTimeout(() => {
- navigator.clipboard.writeText('').catch(() => {});
- if (lastCopiedSecret.current === privateKey) lastCopiedSecret.current = null;
+ if (lastCopiedSecret.current !== privateKey) return;
+ lastCopiedSecret.current = null;
+ navigator.clipboard.writeText('').catch(() => {});
}, 60_000);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/solux/src/components/Settings.tsx` around lines 54 - 61, In Settings.tsx
update the two clipboard-clear timeout callbacks to check the current guard
before calling navigator.clipboard.writeText: in the seed timeout callback
verify lastCopiedSecret.current === mnemonic and only then call writeText('')
and clear lastCopiedSecret.current, and likewise in the key timeout callback
verify lastCopiedSecret.current === privateKey before calling writeText('') and
clearing the ref; this prevents an earlier timer from wiping the clipboard after
a different secret was copied.
Summary
Audit-driven hardening pass across the SentrisCloud frontend monorepo. Closes 2 HIGH + 7 MEDIUM + 3 LOW findings from the 2026-05-13 internal review. All 8 apps build green; pnpm audit went from 29 vulns (11 HIGH) to 1 MODERATE.
HIGH
apps/coinblast/src/app/api/pin/route.ts:45-185): addedcheckOriginallowlist + 10 pins/hour per-IP limit. Defends operator's Pinata quota / billing against drive-by upload spam.apps/coinblast/src/app/api/cb/[...path]/route.ts:14-81): origin gate on POST only; GET stays open. Defence-in-depth on top of indexer's owner-signed-metadata gate.MEDIUM
apps/coinblast/src/app/api/pin/route.ts:21-27): droppedimage/svg+xml. XSS risk if any future component renders via<object>/<iframe>/srcdoc.apps/coinblast/src/app/token/[address]/layout.tsx:45-48): route OG-image rewrite throughipfsToGateway()soNEXT_PUBLIC_IPFS_GATEWAYoverride actually applies.apps/solux/package.json,apps/chain-landing/package.json): bumped next 16.2.3 → 16.2.6 (closes 11 HIGH middleware/proxy advisories on next 16.x).package.json): addedpnpm.overrides.axios ^1.15.2. Closes 10 axios advisories incl. CVE-2025-62718 (NO_PROXY SSRF) + GHSA-q8qp-cvcw-x6jj (prototype-pollution gadgets).apps/faucet/src/app/api/faucet/route.ts:90-101): in production, require CF-Connecting-IP; reject 503 if missing instead of falling through to spoofable XFF.apps/coinblast/src/components/token/BuySellWidget.tsx:64-69): cap user input at 10% (was 15%); add expert-mode confirmation modal for any swap > 5%.apps/dex/src/app/SwapWidget.tsx:60-61): cap at 5% with explicit warning ≥3%; add custom slippage input to SlippageControl.LOW
apps/solux/src/components/Settings.tsx:74): clear on component unmount via useEffect cleanup; toast adds explicit "clear manually" guidance.apps/airdrop/src/components/ClaimWidget.tsx:70): append?v=NEXT_PUBLIC_BUILD_IDquery so cache busts on schema rotation.apps/coinblast/cb-deploy-7-tokens.mjs:42):nativeCurrency.name 'SRX'→'Sentrix'per chain naming canonical..gitignore,apps/solux/.gitignore): added.env.productionto root; dropped!.env.productionallow-list from solux (was the opposite of intent).Skipped
apps/faucet/.env.productionIP — history rewrite is high-risk, operator-driven decision.Test plan
pnpm install— clean, axios pinned to 1.15.2 across transitive pathspnpm -r typecheck— all 9 packages greenpnpm buildper-app: airdrop, chain-landing, coinblast, dex, faucet, landing, scan, solux all pass standalonepnpm audit— 29 (11 HIGH) → 1 MODERATE (postcss inside next 15.5.18, awaiting upstream)/api/pinfrom coinblast.sentriscloud.com — verify origin gate doesn't reject same-origin/api/pinfrom foreign origin — verify 403Summary by CodeRabbit
New Features
Security & Performance
Dependencies