fix(bundle-8): Frontend Hardening — 2 audit findings closed + 3 partial

Closes Audit-2026-04-25 L-015 (Low) and L-019 (Low) — both
verified-already-clean at HEAD; new CI regression guards prevent
regression. Partial closures for M-009, M-010, M-026 — Bundle 8 ships
the helpers + contract tests + a soft CI budget guard, defers the
long-tail per-page migrations to a new tracker ID M-029.

What changed
- web/src/utils/safeHtml.ts (NEW) — sanitizeHtml() chokepoint for
  any future code that genuinely needs dangerouslySetInnerHTML.
  Bundle-8 placeholder body throws — DOMPurify dependency is the
  activation procedure documented in the file header.
- web/src/components/ExternalLink.tsx (NEW) — single chokepoint for
  target="_blank" anchors. Hardcodes rel="noopener noreferrer".
- web/src/hooks/useListParams.ts (NEW) — URL-state hook for filter /
  sort / pagination state on list pages. Canonicalises the existing
  DashboardPage useSearchParams pattern. Per-page migrations of the
  ~14 remaining list pages tracked as M-029.
- web/src/hooks/useTrackedMutation.ts (NEW) — useMutation wrapper
  enforcing the M-009 invalidation contract via discriminated-union
  type: caller MUST declare invalidates: QueryKey[] OR
  invalidates: 'noop' + noopReason: string.
- 4 new Vitest test files — full unit coverage for ExternalLink
  (target/rel preservation), safeHtml (placeholder throws + activation
  hint), useListParams (URL contract / defaults / filter-resets-page),
  useTrackedMutation (invalidate-then-onSuccess / noop variant).
- .github/workflows/ci.yml — three new regression guards:
    Bundle-8 / L-015: greps for any target="_blank" outside ExternalLink
      that lacks rel="noopener noreferrer"; clean at HEAD.
    Bundle-8 / L-019: greps for any dangerouslySetInnerHTML outside
      safeHtml.ts; clean at HEAD (0 sites).
    Bundle-8 / M-009: SOFT budget guard — useMutation sites must not
      exceed invalidation sites + 5. At HEAD: 61 mutations vs 82
      invalidations + 5 = 87 budget. Stricter per-site enforcement
      tracked as M-029.

Verification at HEAD
- web/src/ target=_blank sites: 3 (all in OnboardingWizard.tsx)
  — all three already carry rel="noopener noreferrer". L-015 closed.
- web/src/ dangerouslySetInnerHTML sites: 0. L-019 closed.
- useMutation sites: 61 / invalidateQueries: 82 (M-009 budget healthy)

Per-finding mapping
- L-015 closed (CWE-1022) — verified-already-clean + ExternalLink
  component + CI grep guard.
- L-019 closed (CWE-79) — verified-already-clean + safeHtml chokepoint
  + CI grep guard.
- M-009 partial — useTrackedMutation wrapper authored; soft CI budget
  guard. Migrating the 56 existing useMutation sites to the wrapper
  tracked as M-029.
- M-010 partial — useListParams hook authored + tested. Per-page
  migration of the ~14 list pages tracked as M-029.
- M-026 partial — bundle-prompt called for XSS-hardening tests on the
  T-1 deferred allowlist of 14 pages. Bundle 8 ships the testing
  pattern via the new helpers but does NOT execute the per-page
  migrations — tracked as M-029.

NOT addressed in this bundle (deferred to M-029)
- Migrating existing 56 useMutation sites to useTrackedMutation
- Migrating ~14 list pages from local useState to useListParams
- Adding XSS-hardening tests to the 14 T-1-deferred pages

Verification
- npx tsc --noEmit                                     → clean
- npx vitest run on the 4 new Bundle-8 test files     → 15/15 pass
- L-015 grep guard simulation                          → clean
- L-019 grep guard simulation                          → clean
- M-009 budget simulation                              → 61 ≤ 87 (clean)
- go vet ./...                                         → clean (no backend changes)
- python3 yaml.safe_load(api/openapi.yaml)             → clean
- python3 yaml.safe_load(.github/workflows/ci.yml)     → clean

Backwards compatibility
- All 4 new helper files are additive; no existing call sites were
  modified. Existing list pages keep their useState pagination until
  M-029 ships per-page migrations.

Bundle 8 of the 2026-04-25 comprehensive audit. Per-page migration
backlog tracked as new audit finding M-029.
This commit is contained in:
Shankar
2026-04-26 15:10:32 +00:00
parent 221a1cf6ff
commit 6993e4484c
9 changed files with 613 additions and 0 deletions
+71
View File
@@ -825,6 +825,77 @@ jobs:
ALLOWLIST_SIZE=$(echo "$ALLOW" | tr '|' '\n' | wc -l)
echo "T-1 page-coverage guardrail: clean (allowlist size: $ALLOWLIST_SIZE pages deferred)."
- name: Bundle-8 / L-015 target=_blank rel=noopener regression guard
# Audit L-015 / CWE-1022 (reverse-tabnabbing): every <a target="_blank">
# MUST carry rel="noopener noreferrer" so a malicious page at the
# target URL cannot navigate the opener window via window.opener.
# At Bundle-8 close (commit b566355→) all 3 sites in the codebase
# already comply — this guard prevents regression. The
# ExternalLink component (web/src/components/ExternalLink.tsx)
# is the recommended way to add new external links.
run: |
set -e
OFFENDERS=$(grep -rnE 'target=["'"'"']?_blank["'"'"']?' web/src/ 2>/dev/null \
| grep -v 'noopener noreferrer' \
| grep -v 'web/src/components/ExternalLink.tsx' \
|| true)
if [ -n "$OFFENDERS" ]; then
echo "L-015 regression: target=\"_blank\" without rel=\"noopener noreferrer\":"
echo "$OFFENDERS"
echo ""
echo "Either add rel=\"noopener noreferrer\" inline,"
echo "or migrate to <ExternalLink> from web/src/components/ExternalLink.tsx."
exit 1
fi
echo "L-015 target=_blank guardrail: clean."
- name: Bundle-8 / L-019 dangerouslySetInnerHTML regression guard
# Audit L-019 / CWE-79 (XSS): no production code may use
# dangerouslySetInnerHTML directly. At Bundle-8 close the codebase
# has 0 sites; future genuine needs MUST route through
# web/src/utils/safeHtml.ts::sanitizeHtml.
run: |
set -e
OFFENDERS=$(grep -rnE 'dangerouslySetInnerHTML' web/src/ 2>/dev/null \
| grep -v 'web/src/utils/safeHtml.ts' \
|| true)
if [ -n "$OFFENDERS" ]; then
echo "L-019 regression: dangerouslySetInnerHTML used outside safeHtml.ts:"
echo "$OFFENDERS"
echo ""
echo "Route through web/src/utils/safeHtml.ts::sanitizeHtml — see file"
echo "header for the activation procedure (DOMPurify dependency)."
exit 1
fi
echo "L-019 dangerouslySetInnerHTML guardrail: clean."
- name: Bundle-8 / M-009 mutation invalidation contract guard
# Audit M-009: every useMutation must either invalidate the
# queries it changes OR document why no invalidation is needed.
# SOFT guard — counts useMutation sites and asserts the budget
# doesn't grow without a corresponding invalidateQueries / setQueryData /
# useTrackedMutation reference. Stricter per-site enforcement is
# tracked as M-029 (covers the long-tail useListParams + useTrackedMutation
# migration of the existing 56 useMutation sites).
run: |
set -e
MUTATIONS=$(grep -rcE 'useMutation\(|useTrackedMutation\(' web/src/ 2>/dev/null \
| awk -F: '{s+=$2} END{print s}')
INVALIDATIONS=$(grep -rcE 'invalidateQueries|setQueryData|removeQueries|invalidates:' web/src/ 2>/dev/null \
| awk -F: '{s+=$2} END{print s}')
echo "M-009 budget — useMutation sites: $MUTATIONS / invalidation sites: $INVALIDATIONS"
# At Bundle-8 close: 56 useMutation + 70 invalidation. We allow
# +5 mutations growth before requiring invalidation parity. If
# the gap widens, audit the new mutation sites for missing
# invalidation pairs.
BUDGET=$((INVALIDATIONS + 5))
if [ "$MUTATIONS" -gt "$BUDGET" ]; then
echo "M-009 regression: $MUTATIONS useMutation sites exceeds invalidation budget ($BUDGET)."
echo "New mutations should pair with invalidateQueries/setQueryData OR migrate to"
echo "useTrackedMutation (web/src/hooks/useTrackedMutation.ts) with explicit invalidates."
exit 1
fi
- name: Forbidden env-var docs drift regression guard (G-3)
# G-3 master closed cat-g-163dae19bc59 (docs-only env vars
# phantom in features.md), cat-g-b8f8f8796159 (6 config-only