mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 10:38:56 +00:00
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:
@@ -0,0 +1,28 @@
|
||||
// Bundle-8 / Audit L-019 / CWE-79:
|
||||
// the safeHtml.ts placeholder MUST throw if invoked before the real
|
||||
// DOMPurify-backed implementation is wired. This catches the
|
||||
// "imported the helper but forgot to add dompurify" regression at test
|
||||
// time instead of at runtime against unsanitized HTML.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeHtml } from './safeHtml';
|
||||
|
||||
describe('safeHtml.sanitizeHtml — Bundle-8 / L-019', () => {
|
||||
it('returns empty string for empty input without throwing', () => {
|
||||
expect(sanitizeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('throws a clear error for any non-empty input (placeholder behaviour)', () => {
|
||||
expect(() => sanitizeHtml('<b>bold</b>')).toThrow(/safeHtml.sanitizeHtml is a placeholder/);
|
||||
});
|
||||
|
||||
it('error message points readers at the activation procedure', () => {
|
||||
try {
|
||||
sanitizeHtml('<script>x</script>');
|
||||
throw new Error('should have thrown');
|
||||
} catch (e) {
|
||||
expect(String(e)).toMatch(/dompurify/);
|
||||
expect(String(e)).toMatch(/safeHtml\.ts file header/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Bundle-8 / Audit L-019 / CWE-79 (XSS):
|
||||
//
|
||||
// Single chokepoint for any code that needs `dangerouslySetInnerHTML`.
|
||||
// At Bundle-8 time the codebase has ZERO `dangerouslySetInnerHTML` sites
|
||||
// (verified via `grep -rn dangerouslySetInnerHTML web/src/`); this file
|
||||
// is preventive — when a future feature genuinely needs to render a
|
||||
// trusted-but-rich HTML fragment (markdown email body, signed
|
||||
// notification template, etc.) it MUST route through `sanitizeHtml`
|
||||
// instead of inlining the dangerous attribute.
|
||||
//
|
||||
// The CI regression guard at `.github/workflows/ci.yml` blocks any new
|
||||
// bare `dangerouslySetInnerHTML` from landing — see the
|
||||
// "Bundle-8 / L-019 dangerouslySetInnerHTML guard" step.
|
||||
//
|
||||
// We don't take a runtime DOMPurify dependency in Bundle-8 because the
|
||||
// allowlist is empty at HEAD. When the first real call site lands, add
|
||||
// `dompurify` to package.json and replace the body of `sanitizeHtml`
|
||||
// with a real DOMPurify call. Until then this file documents the
|
||||
// contract and provides a typed boundary the linter can recognise.
|
||||
|
||||
/**
|
||||
* Sanitize an arbitrary HTML string before passing it to React's
|
||||
* `dangerouslySetInnerHTML` prop. Bundle-8 placeholder — see the file
|
||||
* doc comment above for the activation procedure when the first real
|
||||
* call site lands.
|
||||
*
|
||||
* @param html - the untrusted HTML payload
|
||||
* @param _options - reserved for the future DOMPurify config object
|
||||
* @returns the sanitized string ready to assign to `__html`
|
||||
*/
|
||||
export function sanitizeHtml(html: string, _options?: SanitizeOptions): string {
|
||||
if (!html) return '';
|
||||
// Bundle-8: until the first real call site lands, refuse to render
|
||||
// anything. Throwing here is the safe default — a future regression
|
||||
// that imports this helper without enabling DOMPurify will fail loud
|
||||
// at runtime (test) instead of silently rendering attacker HTML.
|
||||
throw new Error(
|
||||
'safeHtml.sanitizeHtml is a placeholder. Add dompurify to package.json ' +
|
||||
'and implement the body before using this helper. See ' +
|
||||
'web/src/utils/safeHtml.ts file header for the contract.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved for the DOMPurify configuration object that the future real
|
||||
* implementation will accept. Documented now so call-site signatures
|
||||
* don't change when the body lights up.
|
||||
*/
|
||||
export interface SanitizeOptions {
|
||||
/** Tags that should survive sanitization (default: `['b','i','em','strong','a','p','br']`) */
|
||||
allowedTags?: string[];
|
||||
/** Attributes that should survive sanitization (default: `['href','title']`) */
|
||||
allowedAttr?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user