--- title: Project Audit 2026-06 tags: [mortgagefi, audit, security, performance, dependencies] type: audit status: current updated: 2026-06-14 --- # Project Audit — June 2026 A fresh, whole-project review of MortgageFi after a period of inactivity, covering the frontend web3 logic, the server/API layer, the Go `nftcache` service, infrastructure/ops, and dependencies. It complements the deeper, topic-specific [[Security Audit]] and [[Performance Audit]] — where those go further on a topic, they are linked inline. > [!danger] Three issues need attention before anything else > 1. **A live secret is shipped to every browser** — `NEXT_PUBLIC_SCHEDY_API_KEY` is set in `.env`, so it is inlined into the client bundle. **Rotate the Schedy key now** and remove the `NEXT_PUBLIC_` variant. > 2. **The scheduler's SSRF guard is bypassable** — it validates the hostname string at *creation* time but the cron worker fires the request later with no re-validation, no redirect control, and no DNS-rebinding protection. > 3. **`GET /api/tasks` and `DELETE /api/tasks/[id]` have no authentication** — anyone can enumerate every scheduled task (and the secrets stored in their headers) or delete them. ## Executive summary The codebase is, structurally, in good shape: a modern and internally-consistent core stack (Next.js 16, React 19, wagmi v3 + viem v2, TypeScript strict), a clean scheduling migration to Next.js API routes + Upstash Redis, non-root Docker images that aren't port-published, and genuine security thought (RPC allowlist, known-preset compound guard, SSRF allowlist scaffolding, a real CSP). The problems are concentrated in **secret handling**, the **outbound-request (SSRF) trust model**, **endpoint authentication**, and the **NFT-scanning design** (both the frontend `ownerOf` walk and the backend serial scan are correctness-fragile and DoS-prone). There is also notable **dead weight**: two unused web3 stacks (`@web3-react`, `@web3modal/wagmi`) and the orphaned `schedy` submodule that Docker still builds and runs. ### Findings by severity | Domain | 🔴 Critical | 🟠 High | 🟡 Medium | 🟢 Low | ⚪ Info | |---|:--:|:--:|:--:|:--:|:--:| | [Frontend web3](#1-frontend-web3--ui-logic) | 0 | 3 | 7 | 4 | 1 | | [API & server libs](#2-api--server-libs) | 2 | 3 | 3 | 2 | 1 | | [nftcache (Go)](#3-nftcache-go-service) | 2 | 4 | 4 | 3 | 2 | | [Infra & ops](#4-infrastructure--ops) | 2 | 3 | 3 | 2 | 2 | | [Dependencies & build](#5-dependencies--build) | 0 | 2 | 2 | 2 | 1 | | **Total** | **6** | **15** | **19** | **13** | **7** | > [!note] Severity is engineering judgement, not a formal CVSS score. The two "Critical" deps/web3modal-style items were normalised down because the affected code is unused; see each finding. ## Priority remediation roadmap > [!todo] Do first (Critical) > - [ ] **Rotate the Schedy API key** and delete `NEXT_PUBLIC_SCHEDY_API_KEY`; proxy Schedy through a server-side route that reads the non-public `SCHEDY_API_KEY`. Remove the stray `manwe-secret` line. > - [ ] **Rewrite `lib/ssrf-guard.ts` to validate at fire-time**: resolve all A/AAAA records, reject any private/reserved/loopback/link-local IP, pin the connection to the validated IP (defeat rebinding), set `redirect: 'manual'` and re-validate each hop, and parse decimal/octal/hex/IPv4-mapped-IPv6 forms with a real IP library. > - [ ] **Authenticate `GET /api/tasks` and `DELETE /api/tasks/[id]`**, scope tasks to an owner, and never return raw `headers`/`payload`. > - [ ] **nftcache: move the API-key check to the top of every handler** (before any param parsing, cache access, or RPC), and **restrict `nft_contract`/`network` to the YAML allowlist** so an arbitrary address can't trigger a 10k-call scan. > - [ ] **Audit published images for baked-in secrets** (`COPY . .` + missing frontend `.dockerignore`); add a `.dockerignore` excluding `.env*`, then rotate anything that may have shipped. > [!todo] Do next (High) > - [ ] Make `CRON_SECRET` (frontend) and `X-API-Key` (nftcache) comparisons constant-time (`crypto.timingSafeEqual` / `crypto/subtle`). > - [ ] Fail the stack closed when `REDIS_REST_TOKEN` is unset; stop shipping `local-redis-token-change-me` as a working default. > - [ ] Make the rate limiter atomic + fail-closed, and stop trusting client `X-Forwarded-For` for the key. > - [ ] Replace the frontend's sequential `ownerOf` gap-scan with `Transfer`-log / enumerable / multicall detection; only count a "gap" on a true `nonexistent token` revert, and never permanently mark a wallet "complete" after transient errors. > - [ ] Give nftcache HTTP-server timeouts + graceful shutdown (`cache.Close()`), thread `context` through RPC calls, and add single-flight to the stale-refresh path. > - [ ] Add nginx `limit_req` / `client_max_body_size`; confirm ntfy isn't open (`NTFY_AUTH_DEFAULT_ACCESS=deny-all`). > - [ ] Gate write actions (`pay`/`approve`/`compound`) on `chainId === selectedChainId`; scope the compound preset check to the selected chain. > - [ ] Remove `@web3-react/*` and `@web3modal/wagmi`; remove the orphaned `schedy` service from `docker-compose.yml`/`Dockerfile`. --- ## 1. Frontend web3 & UI logic > Files: `app/dapp/page.tsx`, `config/web3.ts`, `providers/Web3Provider.tsx`, `utils/scheduler.ts`, `utils/useLocalStorage.ts`, `components/SettingsModal.tsx` The web3 read plumbing is mostly sound (on-chain `decimals`/`symbol`, `parseUnits`/BigInt money math, payment capped to `currentPaymentPending`). The weak spots are the NFT-discovery heuristics and several correctness traps around chain switching. ### 🟠 High - **Gap-limit scan conflates RPC errors with non-existent tokens** — `app/dapp/page.tsx:339-392`. Any non-429 error is treated as a "definitive gap"; 5 in a row write `complete: true`, after which `scanMore` early-returns forever and the wallet looks empty — a safety-relevant false negative that can hide a position nearing liquidation. *Fix:* only count a gap on an actual `nonexistent token` revert; persist a "stopped due to errors" state. - **Sequential scan assumes contiguous tokenIds** — `app/dapp/page.tsx:325-371`. Walks `0,1,2,…` and stops after 5 misses; burns/non-sequential IDs are undetectable except by manual entry. *Fix:* prefer `Transfer`-log scan or ERC721Enumerable (`tokenOfOwnerByIndex`) — see the dead-code finding below. - **Chain-switch race** — `app/dapp/page.tsx:429-437`. `selectedChainId` drives every read while the wallet may be on another chain; the auto-`switchChain` result is ignored and reads/writes proceed regardless. *Fix:* gate writes on `chainId === selectedChainId`, await `switchChainAsync`, prompt the user instead of silent auto-switch. ### 🟡 Medium - **APR display is a magnitude heuristic** — `:991-1003`. Infers scale at runtime (`>1e9 ⇒ 1e18 scale, else basis points`); any other denominator is mis-rendered, potentially off by orders of magnitude for a financial figure. *Fix:* read/hardcode the real per-preset denominator; unit-test against on-chain value. - **Loan-term/expiration heuristic** — `:1226-1237`. `expiration <= 200 ? "N Years" : date`. Conflates two meanings of one field by magnitude. *Fix:* format deterministically from the ABI's actual semantics. - **Provider tokens & API keys in `localStorage`, logged to console** — `:116-118`, `:1404`; `SettingsModal.tsx:330-334`; `useLocalStorage.ts`. Gotify token, ntfy topic, emails, NFTCache API key persisted and the whole settings object repeatedly `console.log`'d. *Fix:* strip the logging; treat the tokens as secrets (don't persist, or schedule server-side). - **`nftcacheApiKey` saved but no UI to view/clear it** — `SettingsModal.tsx:36,51-56`. Half-wired secret that can be persisted but never edited or removed via the UI. *Fix:* add the field or remove the state. - **Custom-RPC trust model is partial & silent** — `config/web3.ts:11-49`. The allowlist only governs read transports (not the wallet's signing RPC), the Alchemy regex accepts any key-shaped suffix, and a rejected RPC silently falls back to a public default with only a `console.warn`. *Fix:* surface rejection in the UI; document the scope. - **Monolithic component re-renders + un-debounced auto-reschedule** — whole file; effects at `:250-304`, `:929-955`. ~25 hooks in one component; every keystroke re-renders the tree, and the auto-reschedule effect fires `scheduleJob`/cancel on >60s drift with `exhaustive-deps` disabled — risking duplicate/cancelled jobs from stale closures. *Fix:* extract child components/hooks, debounce, add an in-flight guard. See [[Performance Audit]] for the broader re-render analysis. - **Compound guard checks address but not chain** — `:1017-1039`. Validates `debtAddress` across *all* chains' presets, then writes with `selectedChainId`. *Fix:* scope to `PRESETS[selectedChainId]` and require chain match. ### 🟢 Low - **Dead enumerable code path** — `:481-502`. `tokenOfOwnerByIndex` reads are built then discarded; the more-correct enumeration is never used. *Fix:* implement via `multicall` or delete. - **`parseUnits` failures swallowed in pay/approve handlers** — `:697-736`. Bad input (`"1.2.3"`, `"1e5"`) throws and is only `console.warn`'d; the money action silently no-ops. *Fix:* surface parse errors; reuse the render's `amount === null` guard. - **Scheduler fabricates a job id on ambiguous responses** — `utils/scheduler.ts:45-65`. A non-JSON/`id`-less response yields `task_`, so a failed schedule looks successful (false confidence an alert exists). *Fix:* treat missing `id` as failure. - **`useLocalStorage` trusts persisted JSON with no schema validation** — `utils/useLocalStorage.ts:18-39`. Tampered/corrupt storage is accepted as typed settings that drive network requests; double-effect can cause a redundant write. *Fix:* validate shape (zod) before accepting. ### ⚪ Info - **Deep-link payload from `localStorage` drives chain/preset** — `:170-195`. Same-origin, age-bounded, and IDs are later guarded — acceptable; consider range-validating `preset`/`tokenId`. --- ## 2. API & server libs > Files: `app/api/cron/route.ts`, `app/api/tasks/route.ts`, `app/api/tasks/[id]/route.ts`, `lib/redis.ts`, `lib/ssrf-guard.ts`, `lib/task-store.ts`, `utils/cronhost.ts` The skeleton is sound (CRON_SECRET gate, an SSRF allowlist, a rate limiter, header sanitisation) but the SSRF guard doesn't actually protect the outbound request, and two endpoints are unauthenticated. ### 🔴 Critical - **SSRF guard bypassable via DNS rebinding, redirects, and IP encodings** — `lib/ssrf-guard.ts:30-84`; enforcement gap at `app/api/cron/route.ts:36`. Validation runs on the literal hostname at creation time only. Bypasses: (1) DNS rebinding — `attacker.com` passes, then resolves to `169.254.169.254`/`127.0.0.1` at fire time; (2) no `redirect: 'manual'`, so a whitelisted host can 302 to metadata; (3) decimal/octal/hex IPv4 (`http://2130706433/`, `0177.0.0.1`, `127.1`) skip the dotted-quad regex; (4) IPv4-mapped IPv6 (`[::ffff:169.254.169.254]`) and long-form loopback bypass the prefix checks; (5) GCP `metadata.google.internal` and the decimal form of the IMDS address aren't blocked. *Fix:* see the roadmap — resolve-and-pin at fire time. The `BLOCKED_HOSTS` Docker-service-name list confirms an internal network is reachable. (Also tracked in [[Security Audit]].) - **No auth on `GET /api/tasks` & `DELETE /api/tasks/[id]`** — `app/api/tasks/route.ts:65-72`; `app/api/tasks/[id]/route.ts:4-19`. GET returns *every* task including each `url`, full `headers` (which legitimately carry `Authorization`/API keys for the target) and `payload`; DELETE removes any task whose id `startsWith('task_')`. Anonymous credential disclosure + sabotage/DoS, with no per-owner scoping anywhere. *Fix:* authenticate + scope + redact. ### 🟠 High - **Stored task headers carry secrets and are returned/forwarded unredacted** — `app/api/tasks/route.ts:50,59`; `lib/task-store.ts:21`. `sanitizeHeaders` strips only hop-by-hop headers; `Authorization`/`Cookie`/`X-Api-Key` survive, are stored in plaintext, echoed in the POST 201, exposed by the unauthenticated GET, and replayed on every retry. *Fix:* encrypt at rest, never echo, redact on cross-origin redirects. - **Rate limiter fails open + check-then-act race + spoofable key** — `lib/task-store.ts:76-91`; `app/api/tasks/route.ts:6-12`. Returns *allow* when Redis is unconfigured; non-atomic count under concurrency; keyed off client-controlled `x-forwarded-for`; collapses missing-IP callers to one `unknown` bucket. *Fix:* atomic sliding window (Lua/`INCR`+`EXPIRE`), fail closed, trusted-proxy IP only. - **`CRON_SECRET` comparison not timing-safe** — `app/api/cron/route.ts:9-13`. Plain `!==` on the one credential protecting the highest-privilege endpoint. *Fix:* `crypto.timingSafeEqual` over equal-length buffers. ### 🟡 Medium - **`listAllTasks` uses `KEYS` + N+1 `GET`** — `lib/task-store.ts:58-73` (and `listDueTasks` N+1 at `:51-54`). `KEYS` is O(N)/blocking and Upstash-discouraged; an attacker can trigger it repeatedly via the open GET. *Fix:* drive listing off the existing `TASK_ZSET` with `ZRANGE` + `MGET`. - **Cron forces POST + overrides stored content-type inconsistently** — `app/api/cron/route.ts:37,40`. Tasks store no method; the worker always POSTs and hardcodes content-type while letting other arbitrary headers through. *Fix:* make method/content-type explicit and validated; consistent precedence. - **Weak input validation (URL length, payload size, header values)** — `app/api/tasks/route.ts:21-26,50`. No max URL length, no payload-size cap (stored & sent verbatim), header values not type/CRLF-checked. *Fix:* enforce limits; reject control/CRLF chars. ### 🟢 Low - **Internal error detail leaked to clients** — `cron/route.ts:20`, `tasks/route.ts:34,61,70`. `Blocked URL: ${reason}` even discloses which SSRF rule fired. *Fix:* generic client errors, detailed server logs. - **Contradictory localhost policy in the guard** — `lib/ssrf-guard.ts:39-48`. Dev carve-out for `http://localhost` is immediately undone by `BLOCKED_HOSTS`; signals the guard wasn't tested end-to-end. *Fix:* resolve the contradiction; key protocol policy off an explicit flag, not `NODE_ENV`. ### ⚪ Info - **`utils/cronhost.ts` is dead/legacy stub code** — confirmed no imports anywhere. *Fix:* delete it. --- ## 3. nftcache (Go service) > Files: `cmd/nftcache/main.go`, `internal/config/config.go`, `internal/fetcher/rpc.go`, `internal/fetcher/alchemy.go`, `internal/store/store.go`, `go.mod` A BadgerDB-backed stale-while-revalidate cache fronting per-token `ownerOf` scans. Auth and address validation exist but the authorization ordering is fragile and the scan design is a DoS amplifier. ### 🔴 Critical - **API-key check ordering is fragile** — `cmd/nftcache/main.go:82-124,239-245`. The key is checked after param/config resolution; gating currently works but any future edit touching RPC before line 122 would expose an unauthenticated amplifier. *Fix:* check the key first thing in every handler (middleware). - **Serial RPC scan is a DoS amplifier** — `internal/fetcher/rpc.go:55-113`, called from `main.go:172,245`. One request → up to `maxTokenId` (cap 10000) serial `eth_call`s against the upstream, throttled by a *global* 5 TPS limiter, for any caller-supplied `network`+`nft_contract`. 1 HTTP request → up to 10k upstream calls, and one abusive scan starves all legitimate traffic. *Fix:* reject contracts not in the YAML allowlist; bound per-request concurrency; per-call deadlines. ### 🟠 High - **Non-constant-time API-key compare** — `main.go:123,239,262`. *Fix:* `crypto/subtle.ConstantTimeCompare`. - **No HTTP-server timeouts & no graceful shutdown** — `main.go:303`, `main()`. Default server (no Read/Write/Idle timeouts → slowloris), no SIGTERM handler, `cache.Close()` never called (Badger not flushed). *Fix:* explicit `http.Server` timeouts + `server.Shutdown` + `defer cache.Close()`. - **No context/timeout on RPC calls; scans uncancellable** — `rpc.go:23,227`; `FetchAllTokenOwners` takes no `context`. Hung upstream blocks a scan goroutine indefinitely; client disconnect doesn't abort a 10k-call scan; background refreshes are fire-and-forget. *Fix:* `http.Client{Timeout}`, thread `context`, derive refresh ctx from server lifetime. - **Stale-refresh thundering herd (no single-flight)** — `main.go:143-162`. N concurrent stale requests launch N full scans of the same contract. *Fix:* `singleflight` per contract key. ### 🟡 Medium - **Serial scanning instead of batch/multicall** — `rpc.go:65-109`. 1000 tokens ≥200s, 10000 ≥33min. Multicall3 (same address on eth/arb/base) or JSON-RPC batch, ERC721Enumerable, or the already-implemented-but-unused Alchemy `getNFTsForOwner` would collapse this. *Fix:* adopt multicall/Alchemy path. (See [[Performance Audit]].) - **Upstream/internal error detail leaked to clients** — `main.go:182,246,267`. *Fix:* generic messages, server-side logs. - **Rate-limit handling deletes cache → feedback loop** — `main.go:151-154,177-180`. On 429 exhaustion it deletes the entry, so the next request rescans into the already-limited upstream; detection is brittle substring matching. *Fix:* keep stale data + back off; typed/sentinel errors. - **No `network` allowlist; CORS supports `*`** — `main.go:83,38-52`. Bad networks fail deep in the scan; `CORS_ALLOW_ORIGIN=*` is allowed (default empty is safe). *Fix:* validate `network` up front; document `*` must not pair with credentialed flows. ### 🟢 Low - **Leftover "Token 103" debug + unconditional `fmt.Printf`** — `rpc.go:106-111`, `main.go:197-199`. *Fix:* remove special-cases; gate behind a debug flag; use `log` consistently. - **`canonAddr` accepts any 40-hex string; duplicated in two files** — `main.go:64-80`, `rpc.go:141-157`. *Fix:* combine with the allowlist; dedupe the helper. - **`json.Marshal` errors ignored in store writes** — `internal/store/store.go:50,58`. *Fix:* check & propagate. ### ⚪ Info - **No Badger-layer TTL / value-log GC** — `store/store.go:49-66`. Entries never expire on disk (app-side SWR only) → unbounded growth. *Fix:* optional Badger TTL + periodic `RunValueLogGC`. - **Rate limiter is approximate (burst >5 TPS) + refill goroutine leaks on exit** — `rpc.go:24-43`. Acceptable for a soft limiter; use `x/time/rate` if strictness is needed. ### Stale dependencies `go 1.22.0` (current 1.24+), `badger/v4 v4.2.0`, and several stale indirect deps with advisories — notably `golang.org/x/net v0.7.0` (HTTP/2 rapid-reset family), `google.golang.org/protobuf v1.28.1` (< 1.33.0 JSON unmarshal loop), `golang.org/x/sys v0.5.0`. *Fix:* `go get -u ./...`, bump the Go directive, run `govulncheck`. --- ## 4. Infrastructure & ops > Files: `docker-compose.yml`, `nginx/nginx.conf`, `.env.example`, `.env` / `.env.local`, `config/contracts.yaml`, both `Dockerfile`s, `next.config.ts` Self-hosted Compose stack. Architecture is reasonable (internal services not port-published, non-root images, a real CSP) but secret handling and the nginx edge need work. ### 🔴 Critical - **Secret shipped to the client via `NEXT_PUBLIC_`** — `.env` / `.env.local`. A real Schedy API key is set as both `SCHEDY_API_KEY` *and* `NEXT_PUBLIC_SCHEDY_API_KEY`; the latter is inlined into the JS every browser downloads. A stray `manwe-secret` duplicates the WalletConnect id. *Fix:* delete the `NEXT_PUBLIC_` variant, proxy Schedy server-side, **rotate the key**, remove the stray line. - **Live third-party secrets on disk** — `.env`, `mortgagefi-frontend/.env.local`. Contains real `ALCHEMY_API_KEY`, `NFTCACHE_API_KEY`, `SCHEDY_API_KEY`, `CRONHOST_API`, and a Gmail SMTP app password. Root `.gitignore` covers them and this isn't a git repo *today*, but one `git init`/sub-repo commit away from exposure. *Fix:* secret manager / Docker secrets; verify the frontend subdir's own `.gitignore` covers `.env.local`; ensure Docker build context doesn't bake them in (next finding). ### 🟠 High - **Weak hardcoded default Redis token** — `docker-compose.yml:16,76`; `.env.example:23`. `SRH_TOKEN`/`UPSTASH_REDIS_REST_TOKEN` default to `local-redis-token-change-me`, shipped as a working value in `.env.example`; the stack is likely running with it. Anyone on the Docker network can then read/write all scheduling state. *Fix:* `${REDIS_REST_TOKEN:?set me}` (fail closed); set a strong random token. - **`COPY . .` may bake `.env` into the frontend image** — `mortgagefi-frontend/Dockerfile:17`. No confirmed `.dockerignore`; the `.env.local` target lives inside the frontend dir, and `NEXT_PUBLIC_*` values inline into the bundle in the published image `…/mortgagefi-frontend:alert`. *Fix:* add `.dockerignore` (`.env*`, `node_modules`, `.next`, `data/`); inspect the published image; rotate anything baked in. - **No rate limiting / body-size limit / edge auth on proxied paths** — `nginx/nginx.conf`. Port 80 forwards `/ntfy/` (full UI + publish API), `/nftcache/`, and the app with no `limit_req`, `limit_conn`, or `client_max_body_size`; security rests entirely on each service's own auth (ntfy default is open). *Fix:* add `client_max_body_size` + `limit_req` zones; `auth_basic`/IP-allowlist `/ntfy/`; set `NTFY_AUTH_DEFAULT_ACCESS=deny-all`. ### 🟡 Medium - **Cron loop swallows all errors + plaintext Bearer + spurious deps** — `docker-compose.yml:84-102`. `curlimages/curl:latest` loop with `|| true` hides every failure (no alerting/dead-man's-switch); depends on `redis` it never uses. *Fix:* log non-2xx (drop `|| true`), pin the image, drop the redis dep, confirm `/api/cron` 401s on bad secret and isn't reachable via nginx `/`. - **Mutable / `:latest` image tags** — `docker-compose.yml:11,23,67,85,105`. `serverless-redis-http:latest`, untagged `ntfy`, mutable `mortgagefi-frontend:alert`, `curl:latest`. *Fix:* pin to digests/immutable tags. - **No healthchecks on any service** — `docker-compose.yml`. `depends_on` only waits for start, not readiness → startup 502s, no auto-restart on hung process. *Fix:* add `healthcheck` + `condition: service_healthy`. ### 🟢 Low - **Duplicate YAML key in nftcache config** — `config/contracts.yaml:10-18`. `cbbtc` defined twice (placeholder vs real); YAML silently keeps the last. *Fix:* remove the placeholder. - **Security-header drift between nginx and Next.js** — `nginx/nginx.conf:6-9,74-79` vs `next.config.ts`. nginx sets no CSP; Next.js does; overlap can diverge on error pages. *Fix:* centralise headers in one layer (prefer the Next.js CSP). ### ⚪ Info - **`proxy_buffering off` is global** — `nginx/nginx.conf:12`. Needed for ntfy SSE but applied everywhere → higher upstream hold time. *Fix:* scope it to `/ntfy/`. - **ntfy uses a personal Gmail app password** — `docker-compose.yml:31-34`. *Fix:* prefer a scoped transactional-mail credential; rotate if ever shared. --- ## 5. Dependencies & build > Files: `mortgagefi-frontend/package.json`, `tsconfig.json`, `next.config.ts`, `eslint.config.mjs`, `nftcache/go.mod`, `submodules/schedy` Core stack is modern and coherent (Next 16, React 19, wagmi v3 + viem v2, TS strict). The issues are dead weight and a few hardening gaps. ### 🟠 High - **Deprecated, unused, version-mismatched `@web3modal/wagmi@^5`** — `package.json`. v5 targets wagmi v2 but the project runs wagmi v3; it's also superseded by Reown AppKit — and it isn't imported anywhere. *Fix:* remove it; adopt `@reown/appkit` + `@reown/appkit-adapter-wagmi` only if a connect modal is later needed. *(Listed High, not Critical: unused = not exploitable, but it's deprecated + mismatched.)* - **Unused redundant `@web3-react/*` stack** — `package.json`. `@web3-react/core@^8`, `@web3-react/injected-connector@^6` are unmaintained and fully unused; the app is bare wagmi v3. *Fix:* remove both; use `wagmi/connectors` if needed. - **Orphaned `schedy` submodule still built & run by Docker** — `submodules/schedy/`, `docker-compose.yml`, `Dockerfile:22`. Scheduling moved to Next.js API routes + Upstash; all in-source `schedy` references are now legacy-prune code or an SSRF-allowlist token, yet Docker still builds the Go scheduler and sets `NEXT_PUBLIC_SCHEDY_URL`. *Fix:* confirm nothing deploys it, then delete the submodule, the `schedy` service/`depends_on`, and `NEXT_PUBLIC_SCHEDY_URL`; clean residual comments/keys. ### 🟡 Medium - **nftcache Go module on an old toolchain with stale transitive deps** — `nftcache/go.mod`. (Same set as §3.) *Fix:* bump Go + `go get -u ./...` + `govulncheck`. - **CSP allows `'unsafe-eval'` and `'unsafe-inline'` for scripts** — `next.config.ts`. Meaningfully weakens XSS protection for a wallet-signing dApp. *Fix:* move toward nonce/hash-based `script-src`, drop `'unsafe-eval'` if wagmi/walletconnect tolerate it; drop the deprecated `X-XSS-Protection`; consider adding HSTS. ### 🟢 Low - **Packages a minor behind (some security-relevant)** — `npm outdated`: `next` 16.0.10 → 16.2.9, `wagmi` 3.1.0 → 3.6.16, `viem` 2.42.1 → 2.52.2, `@tanstack/react-query`, `tailwindcss`, `framer-motion`. Treat Next.js patch bumps as security-relevant. *Fix:* run in-range minors; evaluate eslint 10 / TS 6 majors separately. - **Minimal ESLint config** — `eslint.config.mjs`. Only Next defaults; no `no-floating-promises`/`no-misused-promises` despite fire-and-forget `await fetch` patterns. *Fix:* add `@typescript-eslint` recommended-type-checked rules. ### ⚪ Info - **TS `target: ES2017` is dated** — `tsconfig.json` (strict is otherwise good). *Fix:* optionally raise to ES2022, consider `noUncheckedIndexedAccess`. --- ## What's done well - On-chain `decimals`/`symbol` and BigInt/`parseUnits` math throughout — no float drift in money paths; payments capped to the reset-timer amount. - Genuine security scaffolding: RPC allowlist, known-preset compound guard, SSRF allowlist + header sanitisation, `CRON_SECRET`-gated cron that fails closed, future-dating bounds on `execute_at`, unguessable `crypto.randomUUID()` task ids. - nftcache: API key required at startup (fails fast if unset), CORS closed by default, correct Badger transaction usage, stale-while-revalidate for latency, 429 backoff. - Infra: internal services not port-published, non-root + distroless images, `CGO_ENABLED=0`, read-only `config` mount, consistent `platform` pinning, a real CSP + security-header baseline, correct `.gitignore`, `output: 'standalone'`. - Clean, self-contained scheduling migration to Next.js API routes + Upstash Redis. ## Related [[Home]] · [[Architecture]] · [[Security Audit]] · [[Performance Audit]] · [[Deployment]] · [[Development]]