Skip to content

Monorepo Security Review — Delta (2026-05-14 → 2026-06-25)

Section titled “Monorepo Security Review — Delta (2026-05-14 → 2026-06-25)”

The delta is clean. No new critical or high findings. All four substantive issues from the 2026-05-14 review (C1 CORS wildcard, I1 missing sd-app CSP, I2 tracked .wrangler/, I3 mbr .gitignore missing .env) have been resolved. The high-churn areas — the new set:html-based Tooltip/Glossary pipeline, the mBR rates dashboard, the FastAPI surface, and the focus-page PWAs — were built with the right instincts: parameterized SQL, env-driven CORS allow-lists, auto-escaped data-bound attributes, rel="noreferrer" on all external links, and build-time-only set:html fed exclusively from developer-authored KB content (no runtime/user input reaches any HTML sink). The only real action items are dependency hygiene (an Astro/Vite/transitive-dep bump — mostly dev-only and/or non-applicable to static output) and a couple of low-severity defense-in-depth notes. No secrets are present in the delta.

SeverityCount
Critical0
High0
Medium1 (dependency posture)
Low4
Prior IDIssueStatus (2026-06-25)
C1sd-api wildcard CORSRESOLVEDpackages/sd-api/main.py:27-32 now uses an env-driven allow-list (SD_API_ALLOWED_ORIGINS) with a named-origin default; methods limited to GET,POST; headers to Content-Type.
I1sd-app missing CSPRESOLVEDapps/sd-app/static/_headers now ships a full CSP (default-src 'self', object-src 'none', base-uri 'self', form-action 'self', etc.) plus HSTS and Permissions-Policy.
I2tracked .wrangler/ artifactsRESOLVED — `git ls-files
I3mbr .gitignore missing .envRESOLVEDsites/mbr/.gitignore:7-9 has .env / .env.* / !.env.example.
I4wrangler.toml placeholder IDsNot re-checked in depth; placeholders remain placeholders. No regression.
M1template CSP 'unsafe-eval'Still present by design — commit d2b686d “restore ‘unsafe-eval’ for Alpine.js”. Accepted tradeoff (Alpine needs it). Carried forward as L4 below.
M2–M4trusted-but-fragile set:html/innerHTMLStill present + new instances added (Tooltip, focus.astro). All remain fed by trusted build-time content. See L1.

[SEVERITY: medium] [effort: 2h] Dependency tree behind on Astro/Vite — 14 high, 18 moderate advisories (mostly dev-only or non-applicable to static output)

Section titled “[SEVERITY: medium] [effort: 2h] Dependency tree behind on Astro/Vite — 14 high, 18 moderate advisories (mostly dev-only or non-applicable to static output)”
  • Where: sites/{mbr,sdc,template,ts}/package.json pin astro: ^5.15.3; pnpm audit --prod (run from repo root) reports 39 advisories.
  • What: The high-severity hits break down as:
    • Dev-tooling only (not shipped to static dist/): rollup (arbitrary file write), vite (arbitrary file read / server.fs.deny bypass — dev server only), svgo (DoS), picomatch (ReDoS), h3 (SSE injection), defu (prototype pollution), launch-editor (NTLMv2 disclosure). These run at build/dev time on a trusted local machine — no runtime exposure on the deployed static sites.
    • Astro framework: “Reflected XSS via unescaped slot name” and “Host header SSRF in prerendered error page” are SSR-runtime vulns. All four sites are output: 'static' with no adapter (verified in astro.config.mjs:9), so there is no request-time server processing untrusted Host headers or slot names — these do not apply at runtime. “XSS in define:vars” and “XSS via unescaped attribute names in spread props” could matter only if untrusted data flowed through those constructs at build; in this repo all such data is developer-authored KB/JSON. Practical runtime risk: low.
  • Why flagged medium (not low): Being a full major version behind (5.15.3 vs the 6.x patch line) is a posture issue — it compounds, and the “this advisory doesn’t apply because we’re static” reasoning is fragile (one future output: 'server' or @astrojs/cloudflare adapter addition silently activates the SSR XSS/SSRF class). Keeping current is cheap insurance.
  • Fix: Schedule an Astro 6.x upgrade (pnpm up astro@latest per site, then pnpm test:e2e + deploy-gate). For the dev-only transitive vulns, pnpm up -r / pnpm dedupe to pull patched vite/rollup/picomatch. Re-run pnpm audit --prod and confirm the high count drops. Not deploy-blocking given the static-output mitigation, but do it this sprint.

[SEVERITY: low] [effort: 1h] New set:html sinks (Tooltip, focus.astro, Glossary pipeline) are safe today but undocumented as trust-boundary-critical

Section titled “[SEVERITY: low] [effort: 1h] New set:html sinks (Tooltip, focus.astro, Glossary pipeline) are safe today but undocumented as trust-boundary-critical”
  • Where:
    • packages/components/src/ui/Tooltip.astro:17set:html={text} (renders the text prop as HTML).
    • sites/mbr/src/pages/focus.astro:447,448,451,460,461,467set:html on section headings/bodies/items.
    • sites/mbr/src/utils/load-focus-content.ts:73-85injectGlossaryTooltips() / injectBrands() build raw HTML strings that flow into the above sinks.
  • What: All of this HTML originates from build-time, developer-authored sources: /mnt/d/FSS/KB/MBR/_WorkingOn/Focus.md and /mnt/d/FSS/KB/Core/Misc/Glossary.md, read via readFileSync at build and baked into static HTML. No user/runtime/CMS input reaches these sinks. The Tooltip text prop is likewise passed literal strings by callers. Safe today. The risk is purely fragility: the Props JSDoc on Tooltip.astro:3 advertises “Accepts HTML” without a trust caveat, and the focus loader has no comment marking the KB content as a trust boundary. If a future contributor wires any externally-sourced or user-submitted string (a form value, a query param, a fetched feed) into Tooltip text or the focus pipeline, it becomes a stored/reflected XSS sink — and CSP would not fully save it (mbr/template CSP allows 'unsafe-inline' for styles and 'unsafe-eval' for Alpine).
  • Fix: (defense-in-depth, no functional change) Add a one-line trust comment at each sink — e.g. on Tooltip.astro Props: // text is rendered as raw HTML — pass only trusted build-time content, never user input, and on load-focus-content.ts:73: // Source is developer-authored KB markdown read at build time — not user input. Optionally, if Tooltip ever needs to accept untrusted text, add a <slot>-based text-only path.

[SEVERITY: low] [effort: 0.5h] Glossary/brand HTML built by naive regex string-concat — escaping depends entirely on trusted input

Section titled “[SEVERITY: low] [effort: 0.5h] Glossary/brand HTML built by naive regex string-concat — escaping depends entirely on trusted input”
  • Where: sites/mbr/src/utils/load-focus-content.ts:62-66 (injectBrands), 73-80 (injectGlossaryTooltips), 97-113 (loadGlossaryTerms).
  • What: Term names and definitions from Glossary.md are interpolated directly into HTML template literals (<span class="ft" ...>${term}<span ...>${def}</span></span>) with no escaping — only **bold**<b> transformation. Because the glossary is the developer’s own file, this is fine. But it means the safety property is “the author never writes a < or " they didn’t intend as HTML” — there is no structural guard. Same class as L1, called out separately because it’s the producer side and a likely future copy-paste source for other pages.
  • Fix: No change required for current trust model. If the glossary ever ingests third-party content, HTML-escape term/def before interpolation (or render via a templating layer that escapes by default).

[SEVERITY: low] [effort: 0.25h] sd-api has no authentication or rate limiting on the compute endpoint

Section titled “[SEVERITY: low] [effort: 0.25h] sd-api has no authentication or rate limiting on the compute endpoint”
  • Where: packages/sd-api/main.py:37-39 (POST /api/v1), dispatched into sd_math strategy calculations.
  • What: The analyze endpoint is unauthenticated and unthrottled. CORS now restricts browser origins (good), but CORS is not an access control — any non-browser client (curl, script) can call it directly. The compute path runs historical-window financial simulations that can be moderately expensive, and db.py:get_price_rows() issues a SQLite query per request. There’s no abuse ceiling. This is acceptable for a low-traffic calculator behind Cloudflare, but worth noting before any public launch or if the API ever moves behind a metered/billed resource. Inputs themselves are well-validated (Pydantic discriminated union, Literal enums, typed numeric fields — schemas/inputs.py), and SQL is parameterized (db.py:47-58), so the risk is resource-exhaustion / cost, not injection.
  • Fix: Before public exposure, front the API with a rate limit (Cloudflare WAF rate-limiting rule, or slowapi middleware) and consider a lightweight shared-secret header or Cloudflare Access if it’s only meant to serve the SD app. Not needed while the API is internal/unshipped.

[SEVERITY: low] [effort: 0.5h] CSP retains 'unsafe-inline' (style+script) and 'unsafe-eval' (Alpine) on mbr/template

Section titled “[SEVERITY: low] [effort: 0.5h] CSP retains 'unsafe-inline' (style+script) and 'unsafe-eval' (Alpine) on mbr/template”
  • Where: sites/mbr/public/_headers:14, sites/template/public/_headers:13.
  • What: script-src 'self' 'unsafe-inline' 'unsafe-eval'. 'unsafe-eval' is required by Alpine.js (restored deliberately in commit d2b686d); 'unsafe-inline' covers inline scripts/styles. This materially weakens the CSP’s ability to contain any injected-HTML XSS (relevant given the set:html sinks in L1, though those are currently trusted). The sd-app CSP correctly omits 'unsafe-eval' (no Alpine). Carried forward from prior M1 — still an accepted tradeoff, re-flagged for completeness.
  • Fix: Long-term, migrate inline scripts to hashed/nonce form via a Cloudflare Pages Functions middleware so 'unsafe-inline' can be dropped from script-src. 'unsafe-eval' stays as long as Alpine is used. Low priority; accept for now.

  • Prior critical/high all fixed (CORS allow-list, sd-app CSP, untracked .wrangler, mbr .env ignore).
  • No secrets in the deltagit diff 2026-05-14 HEAD over added/modified lines matched zero real credentials (API keys, tokens, passwords); .env.example files hold only PUBLIC_* placeholders and a non-secret CF project name.
  • Client-exposed env vars are correctly PUBLIC_-prefixed and non-sensitivePUBLIC_FATHOM_SITE_ID, PUBLIC_WAITLIST_ENDPOINT (sites/mbr/src/layouts/BaseLayout.astro:11, WaitlistForm.astro:4). No secret is shipped to the client bundle.
  • sd-api input validation is solid — Pydantic models with a strategy-discriminated union, Literal enums for every mode field, typed numerics, and explicit 404/422 HTTPExceptions for unknown profiles / missing custom data (schemas/inputs.py, router.py:157-183).
  • sd-api SQL is fully parameterizeddb.py:47-58 uses ? placeholders for ticker lookups; no string interpolation into queries; DB opened read-only-by-convention and closed in finally.
  • All external lender links carry rel="noreferrer"sites/mbr/src/pages/rates.astro (9 target="_blank" links, every one with rel). apply_url is rendered as an auto-escaped Astro attribute expression from committed rates.json.
  • Package rename (a741aa2) is clean@smart-debt/components@monorepo/components was the only rename; remaining @smart-debt/* references (design-tokens, i18n, sd-app) are intentionally unchanged and consistent. No broken/dangling import found.
  • PWA cache-control was correctly hardened — commits 2c4913d + 42d76a3 added Cache-Control: no-cache, must-revalidate on /focus.html (both _headers for Cloudflare and .htaccess for the FTP host) plus bfcache (pageshow/persisted) and stale-session (visibilitychange + 5-min gate) reload handlers. This closes the stale/poisoned-PWA-content concern rather than introducing one. Immutable long-cache is correctly scoped to fingerprinted /_astro/* and /fonts/* only.

  1. This sprint (not deploy-blocking): Medium — bump Astro to 6.x and run pnpm up -r to clear dev-dep advisories; re-audit.
  2. Quick defense-in-depth pass (1 PR): L1 + L2 trust-boundary comments on the set:html sinks and glossary builder.
  3. Before any public sd-api exposure: L3 — add rate limiting / access control.
  4. Backlog: L4 — CSP nonce migration to drop 'unsafe-inline' from script-src.