Skip to content

chore: 2nd-pass audit catches — SSRF guard tests, theme_url scope, defense-in-depth#38

Merged
heznpc merged 2 commits into
mainfrom
chore/2nd-pass-audit-2026-05-21
May 28, 2026
Merged

chore: 2nd-pass audit catches — SSRF guard tests, theme_url scope, defense-in-depth#38
heznpc merged 2 commits into
mainfrom
chore/2nd-pass-audit-2026-05-21

Conversation

@heznpc
Copy link
Copy Markdown
Member

@heznpc heznpc commented May 21, 2026

Summary

Adversarial 2nd-pass audit on the 'done' state from PRs #35/#36/#37,
then a /code-review adversarial sweep on this PR's own first commit.
Together they caught 15 defects the first pass missed (including 2 P0).

Test count: 183/183 (was 158 before this branch, 164 after the first
commit on this branch, +19 in this revision).

Findings acted on

P0 (production-broken)

  • Hashnode retired. gql.hashnode.com returns 301 to retirement page
    (verified live). source=hashnode on /api/posts has been silently
    failing every request. Disabled with a clean "use source=rss" error.
  • theme-url.js timer didn't cover the body read. clearTimeout was
    in an inner finally, leaving await res.text() unprotected — the
    exact pattern posts.js's comment warns about. Fixed by restructuring
    the try block to wrap both fetch + body read.

Major

  • fetchCapped + theme-url fetcher: streaming byte-count cap via
    res.body.getReader(). Cap is now in real bytes (not UTF-16 chars)
    and aborts mid-stream so chunked / no-content-length responses
    cannot OOM the function before the post-read check.
  • Content-length sanitized as non-negative integer — Content-Length: -1
    can no longer skip the pre-read cap.
  • AbortError accuracy: checks controller.signal.aborted before
    translating to "timed out". Undici-side resets (mid-stream) now
    propagate with their own message.
  • fetchCapped tolerates null / { fetchImpl: null } for the
    third options arg (defaults only fire on undefined).
  • theme-url.js gained MAX_BODY_BYTES (256 KB).
  • Both wrappers gained a timeoutMs test override.
  • catalog.js now injects universal params (theme, theme_url, font,
    color, hide, border_radius, card_width) into every card.
    MCP wrapper and external discovery clients now match README claim.
  • /api/stack resolves the top-level theme_url once via the new
    prefetchExternalTheme helper and passes it to every child slot.
    A child overriding with <card>.theme_url=<different> still
    triggers a live fetch.

Test fidelity

  • Mocks switched from Map to real Headers (case-insensitive .get).
  • text() spy on the content-length cap test — pins the pre-read
    contract (body must NOT be materialized).
  • Signal non-overridability test.
  • Byte-vs-char body cap test using multibyte emoji.
  • Timer vs upstream AbortError tests for both fetchers.
  • Catalog discovery tests (theme_url on every card; no dup; hashnode
    retirement signaled in description).
  • Prefetched-palette tests for resolveCardOptions.

Docs

  • SECURITY.md: surfaces aligned with README, streaming-byte-cap
    description, hashnode added to out-of-scope.
  • README: dropped hashnode from posts source list with a note
    pointing users at source=rss against the Hashnode RSS feed.

Test plan

  • npm run check — clean
  • npm test — 183/183
  • CI matrix test (22) / test (24) green

@heznpc heznpc enabled auto-merge (squash) May 21, 2026 13:03
heznpc added 2 commits May 29, 2026 06:50
…fense-in-depth

Previously declared done; adversarial 2nd-pass caught the following:

M1 — SECURITY.md (added in #36) promises four guards on the user-controlled
fetch path (redirect:error, 5s timeout, content-length cap, body-length
cap). theme-url.js had integration tests via an injected fetchImpl;
posts.js had none. A refactor that dropped redirect:error from
src/fetchers/posts.js would have failed zero existing tests.
  - Thread fetchImpl through fetchCapped so its wire behavior is testable
    without monkey-patching globalThis.fetch.
  - Add 6 wire-level tests in tests/posts-security.test.js covering each
    guard plus non-ok status surfacing.
  - Tighten fetchCapped: spread caller init BEFORE the security defaults
    so redirect:error and signal are non-overridable. Caller code today
    doesn't trigger this, but the order made the guard pointlessly weak.

M2 — README:268 "theme_url Currently supported by /api/stats, /api/stack
(Other endpoints will adopt as a follow-up)" was stale. Every card
endpoint already routes through resolveCardOptions. Updated the
"Currently supported by" paragraph and dropped the redundant "theme_url
adoption across the catalog" item from the Planned block.

Minor (inline TODOs only, no behavior change):
  - src/common/card.js: data-cas-target="${titleTarget}" — all current
    callers pass the literal "username", so safe today, but unescaped
    attribute interpolation is a latent regression vector if anyone ever
    threads user input through. TODO marker.
  - src/fetchers/posts.js: FETCH_TIMEOUT_MS is duplicated in theme-url.js
    with the same value. TODO marker to lift to common/utils.js if a
    third caller arrives.

Out-of-scope (verified clean, no fix needed):
  - SVG XSS surface: every <text>${x}</text> traced; all escaped or
    numeric/internal-static.
  - CRLF in X-Theme-Error: blocked at both URL parser and setHeader
    layers (empirically verified on Node 22).
  - Secret leakage: git history -S clean for ghp_/BEGIN PRIVATE KEY etc.
  - eval/Function/vm/child_process: 0 occurrences.
  - Telemetry/tracking pixels: 0 third-party calls (README claim
    verified).
  - cards.test.js "8 asserts / 244 LOC" was a misread — the wrapper
    runs ~3700 effective assertions across themes × cards.
Pinned each finding from the /code-review pass on this branch. Tests
grew from 164 → 183.

P0 (production-broken)
- Hashnode source disabled: gql.hashnode.com retired 2026-05 (verified
  live: 301 to retirement announcement). source=hashnode now returns a
  clean "use source=rss against your blog's /rss feed" error instead of
  silently failing the fetch. Removed gql.hashnode.com from the host
  allowlist. README and catalog updated; SECURITY.md notes the disablement.
- theme-url.js timer now covers the body read. clearTimeout had been
  in an inner finally, leaving `await res.text()` unprotected — the
  exact pattern posts.js's own comment warns about.

Hardening
- fetchCapped + theme-url.js fetcher: streaming byte-count cap via
  res.body.getReader(). Cap is now enforced in bytes (not text.length
  UTF-16 code units), and aborts mid-stream so chunked / no-content-
  length responses cannot OOM the function before the post-read check.
- content-length sanitization: Number.isInteger + non-negative gate so
  `Content-Length: -1` from a buggy/hostile upstream can't bypass the
  pre-read cap by exploiting Number('-1') being finite.
- AbortError accuracy: catch now checks controller.signal.aborted
  before translating to "timed out" — Undici can surface AbortError for
  non-timer reasons (mid-stream server reset) and those should propagate
  with their own message.
- fetchCapped tolerates `null` and `{ fetchImpl: null }` for the third
  options arg (defaults only fire on `undefined`); falls back to
  globalThis.fetch in both cases.
- theme-url.js gained MAX_BODY_BYTES (256 KB ceiling for theme JSON).
- Both fetch wrappers gained a `timeoutMs` test-override so unit tests
  can drive the timer-induced abort path without waiting the real 5s.

Catalog / discovery
- catalog.js now injects UNIVERSAL_PARAMS (theme, theme_url, font,
  *_color, hide_*, border_radius, card_width) into every card's
  common_params at response time. The MCP wrapper and any other
  discovery client now sees the same surface README claims.
- The catalog description for /api/posts notes the hashnode retirement.

/api/stack N+1 dedup
- resolveCardOptions gained a `prefetched` option. stack.js resolves
  the top-level theme_url ONCE via the new prefetchExternalTheme helper
  and passes the result to every child slot. A child overriding with
  `<card>.theme_url=<different>` falls through to the normal fetch path.

Test coverage
- Switched test mocks from `new Map()` to real `new Headers()` so the
  case-insensitive get path production uses is actually exercised.
- Added a text() spy on the content-length cap test — pins the pre-read
  contract (the cap must reject BEFORE the body is materialized).
- Added signal non-overridability test (the source comment claimed it;
  no test enforced it).
- Added byte-vs-char body cap test using multibyte emoji.
- Added timer-induced vs upstream-surfaced AbortError tests.
- Added null / { fetchImpl: null } options-bag tests.
- Added catalog.test.js — every card carries theme_url, no duplicates,
  utility endpoints don't inherit card params, hashnode retirement
  signaled in description.
- Added prefetched-palette tests for resolveCardOptions in theme-url.test.js.

Docs
- SECURITY.md: surfaces aligned with README ("every card endpoint" via
  the shared resolver), wire-guard description updated to streaming
  byte cap, hashnode added to out-of-scope.
- README: dropped 'hashnode' from /api/posts source list with a note
  pointing users at source=rss against the Hashnode RSS feed.
@heznpc heznpc force-pushed the chore/2nd-pass-audit-2026-05-21 branch from d21e08a to 3366b53 Compare May 28, 2026 21:50
@heznpc heznpc merged commit de5972f into main May 28, 2026
4 checks passed
heznpc added a commit that referenced this pull request May 28, 2026
Acted on the two ProfileKit-local items from /heznpc-market-pulse:

Major — Related projects comparison in README
- Added a "Related projects" section comparing ProfileKit against
  anuraghazra/github-readme-stats (dominant, ~79.5k★, last commit
  2026-01-18) and lowlighter/metrics (~16.7k★, push 2026-05-22, the
  active 300+-option direct competitor). Differences framed as
  stylistic, not better/worse — composition unit, update model,
  configuration, runtime deps, card set, MCP integration. Includes
  "when ProfileKit fits well" / "when alternatives fit better" so the
  comparison stays honest.

Minor — response-header regression test
- New tests/endpoint-headers.test.js covers:
  · /api/divider — Content-Type: image/svg+xml + Cache-Control max-age
    (Camo cache depends on both)
  · /api/health — application/json + Cache-Control: no-store (probes
    must observe current pool state, not a snapshot)
  · /api/catalog — application/json + max-age (MCP discovery hits this;
    no caching = hammered function)
  Also cross-checks the README/catalog contract that theme_url appears
  in hero.common_params — closes the loop with the catalog test added
  in #38.

Out of scope (verified clean, no fix needed):
- vercel.json + README scan for "edge runtime" mentions — none found.
  Vercel Edge Functions deprecation (2026-04-08) does not affect this
  repo because the functions are already Node serverless.

Out of scope (separately, different repo):
- MCP wrapper plugin-directory submission + "third-party, not Anthropic
  Verified" disclaimer — lives in MCP/profilekit-mcp, not here.
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