fix(security): add Content-Security-Policy headers to SvelteKit responses #116

Open
opened 2026-03-27 17:53:44 +01:00 by marcel · 7 comments
Owner

Problem

The application has no Content-Security-Policy header. Svelte's default output escaping prevents most XSS today, but CSP is the browser-enforced safety net — it blocks script execution even if an injection slips through (e.g. via a future {@html} block, a compromised CDN asset, or a third-party library).

Without CSP, there is no second line of defence.

Approach

Phase 1 — Report-Only (safe, no user impact):

Add Content-Security-Policy-Report-Only first. Violations are logged to the browser console (and optionally a report endpoint) but nothing is blocked. This reveals what a real policy would need to allow before you lock anything down.

// hooks.server.ts
export const handle = sequence(
  userGroup,
  handleAuth,
  handleLocaleDetection,
  handleParaglide,
  // Add last so it wraps the resolved response:
  async ({ event, resolve }) => {
    const response = await resolve(event);
    response.headers.set(
      'Content-Security-Policy-Report-Only',
      [
        "default-src 'self'",
        "script-src 'self'",
        "style-src 'self' 'unsafe-inline'",  // Tailwind requires inline styles
        "img-src 'self' data: blob:",         // PDF.js uses blob: URLs
        "connect-src 'self'",
        "object-src 'none'",
        "frame-ancestors 'none'"
      ].join('; ')
    );
    return response;
  }
);

Phase 2 — Enforce (after observing violations for 1–2 weeks):

Rename to Content-Security-Policy once the policy is confirmed clean.

Additional headers to add at the same time

response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

Acceptance Criteria

  • Phase 1: Content-Security-Policy-Report-Only header present on all page responses
  • No legitimate app functionality causes violations in the browser console
  • Phase 2: Header renamed to Content-Security-Policy with no violations
  • X-Frame-Options, X-Content-Type-Options, Referrer-Policy headers present
## Problem The application has no `Content-Security-Policy` header. Svelte's default output escaping prevents most XSS today, but CSP is the browser-enforced safety net — it blocks script execution even if an injection slips through (e.g. via a future `{@html}` block, a compromised CDN asset, or a third-party library). Without CSP, there is no second line of defence. ## Approach **Phase 1 — Report-Only (safe, no user impact):** Add `Content-Security-Policy-Report-Only` first. Violations are logged to the browser console (and optionally a report endpoint) but nothing is blocked. This reveals what a real policy would need to allow before you lock anything down. ```typescript // hooks.server.ts export const handle = sequence( userGroup, handleAuth, handleLocaleDetection, handleParaglide, // Add last so it wraps the resolved response: async ({ event, resolve }) => { const response = await resolve(event); response.headers.set( 'Content-Security-Policy-Report-Only', [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", // Tailwind requires inline styles "img-src 'self' data: blob:", // PDF.js uses blob: URLs "connect-src 'self'", "object-src 'none'", "frame-ancestors 'none'" ].join('; ') ); return response; } ); ``` **Phase 2 — Enforce (after observing violations for 1–2 weeks):** Rename to `Content-Security-Policy` once the policy is confirmed clean. ## Additional headers to add at the same time ```typescript response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); ``` ## Acceptance Criteria - Phase 1: `Content-Security-Policy-Report-Only` header present on all page responses - No legitimate app functionality causes violations in the browser console - Phase 2: Header renamed to `Content-Security-Policy` with no violations - `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` headers present
marcel added the security label 2026-03-31 20:49:43 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Questions & Observations

  • The phase 1 / phase 2 approach is exactly right — deploy report-only first, observe, then enforce. Don't skip the observation period.
  • The sequence() wrapper in hooks.server.ts — the CSP handler should run last in the sequence so it wraps the fully resolved response, including responses from other hooks. The issue's code shows this correctly with await resolve(event) inside the handler.
  • One thing to verify: does sequence() in this project's hooks.server.ts already exist, and what's in it? Adding a new handler needs to slot in without breaking existing auth/locale handlers.

Suggestions

  • SvelteKit has a built-in CSP mechanism in svelte.config.js (kit.csp) that generates per-request nonces automatically for inline scripts. Consider using it instead of manual header injection — it integrates with SSR rendering and avoids the 'unsafe-inline' concession for scripts:
    // svelte.config.js
    kit: {
      csp: { mode: 'auto', directives: { 'script-src': ['self'] } }
    }
    
  • Write a Playwright test: fetch the response headers on the home page and assert content-security-policy-report-only (Phase 1) is present with the expected directives. This test becomes the guard for Phase 2 enforcement too.
  • Remove the sequence() import if it's already imported — don't duplicate it.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Questions & Observations - The phase 1 / phase 2 approach is exactly right — deploy report-only first, observe, then enforce. Don't skip the observation period. - The `sequence()` wrapper in `hooks.server.ts` — the CSP handler should run *last* in the sequence so it wraps the fully resolved response, including responses from other hooks. The issue's code shows this correctly with `await resolve(event)` inside the handler. - One thing to verify: does `sequence()` in this project's `hooks.server.ts` already exist, and what's in it? Adding a new handler needs to slot in without breaking existing auth/locale handlers. ### Suggestions - SvelteKit has a built-in CSP mechanism in `svelte.config.js` (`kit.csp`) that generates per-request nonces automatically for inline scripts. Consider using it instead of manual header injection — it integrates with SSR rendering and avoids the `'unsafe-inline'` concession for scripts: ```js // svelte.config.js kit: { csp: { mode: 'auto', directives: { 'script-src': ['self'] } } } ``` - Write a Playwright test: fetch the response headers on the home page and assert `content-security-policy-report-only` (Phase 1) is present with the expected directives. This test becomes the guard for Phase 2 enforcement too. - Remove the `sequence()` import if it's already imported — don't duplicate it.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Questions & Observations

  • PDF.js is the main CSP headache. It uses blob: URLs for its worker, data: URIs for inline content, and in some configurations requires wasm-unsafe-eval for WebAssembly. The proposed policy doesn't include worker-src blob: — this will produce violations the moment the PDF viewer is opened. Add it to the policy before Phase 1 ships:
    worker-src blob:;
    
  • 'unsafe-inline' for styles. Tailwind 4 generates utility classes at build time — it shouldn't need 'unsafe-inline' for styles in production. But SvelteKit's Vite-based dev server injects inline styles for HMR. The dev and production profiles may need different CSP headers. Confirm with Content-Security-Policy-Report-Only in staging before enforcing.
  • svelte.config.js nonce approach is better. SvelteKit's built-in kit.csp config generates per-request nonces for inline scripts, which lets you drop 'unsafe-inline' from script-src entirely. The manual header approach in the issue works but misses this opportunity.
  • Phase 2 enforcement timeline. The issue says "1–2 weeks" of observation. Make sure this is tracked — CSP report-only headers that never get enforced provide zero protection.
  • Additional headers listed are all correct. X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and Referrer-Policy: strict-origin-when-cross-origin are the right choices.

Suggestions

  • Add Permissions-Policy: camera=(), microphone=(), geolocation=() while you're adding headers — zero cost, good hygiene.
  • Add a Playwright test that asserts no CSP violations appear in the browser console during a standard document viewing session.
## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Questions & Observations - **PDF.js is the main CSP headache.** It uses `blob:` URLs for its worker, `data:` URIs for inline content, and in some configurations requires `wasm-unsafe-eval` for WebAssembly. The proposed policy doesn't include `worker-src blob:` — this will produce violations the moment the PDF viewer is opened. Add it to the policy before Phase 1 ships: ``` worker-src blob:; ``` - **`'unsafe-inline'` for styles.** Tailwind 4 generates utility classes at build time — it shouldn't need `'unsafe-inline'` for styles in production. But SvelteKit's Vite-based dev server injects inline styles for HMR. The dev and production profiles may need different CSP headers. Confirm with `Content-Security-Policy-Report-Only` in staging before enforcing. - **`svelte.config.js` nonce approach is better.** SvelteKit's built-in `kit.csp` config generates per-request nonces for inline scripts, which lets you drop `'unsafe-inline'` from `script-src` entirely. The manual header approach in the issue works but misses this opportunity. - **Phase 2 enforcement timeline.** The issue says "1–2 weeks" of observation. Make sure this is tracked — CSP report-only headers that never get enforced provide zero protection. - **Additional headers listed are all correct.** `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: strict-origin-when-cross-origin` are the right choices. ### Suggestions - Add `Permissions-Policy: camera=(), microphone=(), geolocation=()` while you're adding headers — zero cost, good hygiene. - Add a Playwright test that asserts no CSP violations appear in the browser console during a standard document viewing session.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Test Strategy

CSP headers are response-header tests — Playwright is the right layer, not unit tests.

E2E — Playwright (Phase 1):

test('CSP Report-Only header is present on all page responses', async ({ page }) => {
  const response = await page.goto('/');
  expect(response?.headers()['content-security-policy-report-only']).toBeDefined();
});

test('no CSP violations during normal document viewing', async ({ page }) => {
  const violations: string[] = [];
  page.on('console', msg => {
    if (msg.type() === 'error' && msg.text().includes('Content Security Policy')) {
      violations.push(msg.text());
    }
  });

  await page.goto('/');
  await page.goto('/documents'); // document list
  // open a document with a PDF
  expect(violations).toHaveLength(0);
});

Phase 2 test (after enforcement):

test('CSP enforcement header is present', async ({ page }) => {
  const response = await page.goto('/');
  expect(response?.headers()['content-security-policy']).toBeDefined();
  expect(response?.headers()['content-security-policy-report-only']).toBeUndefined();
});

Observations

  • The Phase 1 → Phase 2 transition should be tracked as a separate acceptance criterion. Consider adding a follow-up issue for Phase 2 so it doesn't get forgotten.
  • Test all authenticated routes, not just the home page — CSP should apply to the document viewer, the admin panel, and the login page.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Test Strategy CSP headers are response-header tests — Playwright is the right layer, not unit tests. **E2E — Playwright (Phase 1):** ```typescript test('CSP Report-Only header is present on all page responses', async ({ page }) => { const response = await page.goto('/'); expect(response?.headers()['content-security-policy-report-only']).toBeDefined(); }); test('no CSP violations during normal document viewing', async ({ page }) => { const violations: string[] = []; page.on('console', msg => { if (msg.type() === 'error' && msg.text().includes('Content Security Policy')) { violations.push(msg.text()); } }); await page.goto('/'); await page.goto('/documents'); // document list // open a document with a PDF expect(violations).toHaveLength(0); }); ``` **Phase 2 test (after enforcement):** ```typescript test('CSP enforcement header is present', async ({ page }) => { const response = await page.goto('/'); expect(response?.headers()['content-security-policy']).toBeDefined(); expect(response?.headers()['content-security-policy-report-only']).toBeUndefined(); }); ``` ### Observations - The Phase 1 → Phase 2 transition should be tracked as a separate acceptance criterion. Consider adding a follow-up issue for Phase 2 so it doesn't get forgotten. - Test all authenticated routes, not just the home page — CSP should apply to the document viewer, the admin panel, and the login page.
Author
Owner

🏗️ Markus Keller — Application Architect

Questions & Observations

  • svelte.config.js kit.csp vs manual header injection — use the framework. SvelteKit's built-in CSP support (kit.csp.mode: 'auto') generates per-request nonces, injects them into <script> tags at render time, and handles the header — all in one place. The manual hooks.server.ts approach works but bypasses this integration and requires 'unsafe-inline' for scripts. Use the framework mechanism:
    // svelte.config.js
    kit: {
      csp: {
        mode: 'auto',
        directives: {
          'default-src': ['self'],
          'img-src': ['self', 'data:', 'blob:'],
          'worker-src': ['blob:'],
          'object-src': ['none'],
          'frame-ancestors': ['none']
        },
        reportOnly: { /* same directives for Phase 1 */ }
      }
    }
    
  • Phase 1 → Phase 2 transition must be explicit. The reportOnly → enforce flip is a config change, not a code change. Add a follow-up issue now so it doesn't fall off the backlog.
  • The additional headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) belong in hooks.server.ts — they're not managed by kit.csp. These are fine as manual header injections.

Suggestions

  • Decide the approach (framework vs manual) before implementation starts. Both work; picking one and documenting why prevents someone switching it later "to simplify."
## 🏗️ Markus Keller — Application Architect ### Questions & Observations - **`svelte.config.js` `kit.csp` vs manual header injection — use the framework.** SvelteKit's built-in CSP support (`kit.csp.mode: 'auto'`) generates per-request nonces, injects them into `<script>` tags at render time, and handles the header — all in one place. The manual `hooks.server.ts` approach works but bypasses this integration and requires `'unsafe-inline'` for scripts. Use the framework mechanism: ```js // svelte.config.js kit: { csp: { mode: 'auto', directives: { 'default-src': ['self'], 'img-src': ['self', 'data:', 'blob:'], 'worker-src': ['blob:'], 'object-src': ['none'], 'frame-ancestors': ['none'] }, reportOnly: { /* same directives for Phase 1 */ } } } ``` - **Phase 1 → Phase 2 transition must be explicit.** The `reportOnly` → enforce flip is a config change, not a code change. Add a follow-up issue now so it doesn't fall off the backlog. - **The additional headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`) belong in `hooks.server.ts`** — they're not managed by `kit.csp`. These are fine as manual header injections. ### Suggestions - Decide the approach (framework vs manual) before implementation starts. Both work; picking one and documenting why prevents someone switching it later "to simplify."
Author
Owner

🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist

Questions & Observations

  • CSP enforcement can break visual design silently. When Phase 2 enforcement goes live, any inline styles applied by JavaScript — including transitions, dynamic heights, or PDF.js canvas sizing — will be blocked if style-src doesn't include 'unsafe-inline'. A broken layout is a worse UX outcome than a missing security header. The Phase 1 / Report-Only approach is essential — don't skip it.
  • Tailwind 4 and inline styles. Tailwind 4 uses CSS custom properties extensively and should not require 'unsafe-inline' for most utility classes. However, any component that uses Svelte's style: directive or inline style="..." attributes will generate violations. Audit the components before enforcing — the document viewer and PDF controls are likely candidates.

Suggestions

  • During the Phase 1 observation period, pay particular attention to the PDF viewer page and the document detail page — these are the most visually complex and most likely to have inline style usage.
  • No design changes are required for this issue. The security headers are invisible to users when working correctly. The only user-visible impact would be a broken UI from an overly strict policy — which is why the phased approach is the right one.
## 🎨 Leonie Voss — UI/UX Designer & Accessibility Strategist ### Questions & Observations - **CSP enforcement can break visual design silently.** When Phase 2 enforcement goes live, any inline styles applied by JavaScript — including transitions, dynamic heights, or PDF.js canvas sizing — will be blocked if `style-src` doesn't include `'unsafe-inline'`. A broken layout is a worse UX outcome than a missing security header. The Phase 1 / Report-Only approach is essential — don't skip it. - **Tailwind 4 and inline styles.** Tailwind 4 uses CSS custom properties extensively and should not require `'unsafe-inline'` for most utility classes. However, any component that uses Svelte's `style:` directive or inline `style="..."` attributes will generate violations. Audit the components before enforcing — the document viewer and PDF controls are likely candidates. ### Suggestions - During the Phase 1 observation period, pay particular attention to the PDF viewer page and the document detail page — these are the most visually complex and most likely to have inline style usage. - No design changes are required for this issue. The security headers are invisible to users when working correctly. The only user-visible impact would be a broken UI from an overly strict policy — which is why the phased approach is the right one.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Questions & Observations

  • Caddy can also set security headers at the reverse proxy layer via header directives. The question is whether to set CSP in SvelteKit (application layer) or Caddy (infrastructure layer). My preference: application layer wins for CSP because CSP often needs per-route variation (e.g. the PDF viewer needs worker-src blob:, other pages don't). Caddy can handle the static headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) since those don't vary by route.
  • Phase 2 enforcement needs a deployment gate, not just a code change. Before flipping reportOnly to Content-Security-Policy, run the Playwright E2E suite against a staging environment with enforcement active. A CSP violation in production blocks the feature entirely for all users — staging validation is not optional.
  • Permissions-Policy header (as NullX suggests) can be set in Caddy globally with zero application-layer changes — zero cost addition.

Suggestions

  • Add X-Frame-Options, X-Content-Type-Options, and Referrer-Policy to the Caddy config in docker-compose.prod.yml's Caddyfile, not in hooks.server.ts. Keep security header responsibility split: Caddy owns static hardening headers, SvelteKit owns CSP (because it needs nonces and route awareness).
  • Create a follow-up issue for Phase 2 enforcement now, before this PR closes — otherwise the report-only phase runs indefinitely.
## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Questions & Observations - **Caddy can also set security headers** at the reverse proxy layer via `header` directives. The question is whether to set CSP in SvelteKit (application layer) or Caddy (infrastructure layer). My preference: **application layer wins for CSP** because CSP often needs per-route variation (e.g. the PDF viewer needs `worker-src blob:`, other pages don't). Caddy can handle the static headers (`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`) since those don't vary by route. - **Phase 2 enforcement needs a deployment gate**, not just a code change. Before flipping `reportOnly` to `Content-Security-Policy`, run the Playwright E2E suite against a staging environment with enforcement active. A CSP violation in production blocks the feature entirely for all users — staging validation is not optional. - **`Permissions-Policy` header** (as NullX suggests) can be set in Caddy globally with zero application-layer changes — zero cost addition. ### Suggestions - Add `X-Frame-Options`, `X-Content-Type-Options`, and `Referrer-Policy` to the Caddy config in `docker-compose.prod.yml`'s Caddyfile, not in `hooks.server.ts`. Keep security header responsibility split: Caddy owns static hardening headers, SvelteKit owns CSP (because it needs nonces and route awareness). - Create a follow-up issue for Phase 2 enforcement now, before this PR closes — otherwise the report-only phase runs indefinitely.
Author
Owner

Audit confirmation + scope refinement (2026-05-07)

Live curl -I confirms the gap is specifically on the SvelteKit frontend (port 5173 / production node adapter), not the backend:

--- http://localhost:5173/ (frontend SSR) — what the user actually loads ---
HTTP/1.1 302 Found
Vary: Origin
location: /login
(no CSP, no HSTS, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy)

--- http://localhost:8080/api/* (backend) ---
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 0     ← intentional, OWASP recommends 0
Cache-Control: no-store, …
(no CSP, no HSTS — but those belong on the frontend layer)

So the backend already gets Spring Security's defaults; the user-facing frontend ships zero headers. The fix is exclusively on the SvelteKit side.

kit: {
    adapter: adapter(),
    csp: {
        mode: 'auto',
        directives: {
            'default-src': ['self'],
            'script-src': ['self', 'strict-dynamic'],
            'style-src': ['self', 'unsafe-inline'],   // tighten after audit
            'img-src':    ['self', 'data:', 'blob:'],
            'connect-src':['self'],
            'frame-ancestors': ['none'],
            'base-uri':    ['self'],
            'form-action': ['self']
        },
        reportOnly: { 'report-uri': ['/csp-report'] }
    }
}

Plus a securityHeaders step in hooks.server.ts

const securityHeaders: Handle = async ({ event, resolve }) => {
    const response = await resolve(event);
    response.headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
    response.headers.set('X-Content-Type-Options', 'nosniff');
    response.headers.set('Referrer-Policy', 'same-origin');
    response.headers.set('X-Frame-Options', 'DENY');
    response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
    return response;
};
export const handle = sequence(securityHeaders, userGroup, handleAuth, handleLocaleDetection, handleParaglide);

Suggested AC

  • CSP starts in reportOnly mode for one week to surface unintended violations before enforcement.
  • connect-src allowlists exactly the backend host (e.g., https://api.familienarchiv.example); no wildcards.
  • TipTap-rendered comment HTML is server-sanitized via isomorphic-dompurify before persistence (defense in depth).
  • Test: open /documents in Chrome with DevTools → Console; zero CSP violations on a clean load.
  • HSTS only set when Forwarded: proto=https (don't set on bare HTTP staging).

This is the chained-vulnerability mitigator for F-13 — without CSP, an XSS in TipTap content directly exfiltrates the cookie holding Basic <base64(email:password)>.

Tracked in audit doc as F-01 (Critical). See docs/audits/2026-05-07-pre-prod-architectural-review.md.

## Audit confirmation + scope refinement (2026-05-07) Live `curl -I` confirms the gap is **specifically on the SvelteKit frontend** (port 5173 / production node adapter), not the backend: ``` --- http://localhost:5173/ (frontend SSR) — what the user actually loads --- HTTP/1.1 302 Found Vary: Origin location: /login (no CSP, no HSTS, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy) --- http://localhost:8080/api/* (backend) --- X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN X-XSS-Protection: 0 ← intentional, OWASP recommends 0 Cache-Control: no-store, … (no CSP, no HSTS — but those belong on the frontend layer) ``` So the backend already gets Spring Security's defaults; the user-facing frontend ships zero headers. The fix is exclusively on the SvelteKit side. ### Recommended `svelte.config.js` ```javascript kit: { adapter: adapter(), csp: { mode: 'auto', directives: { 'default-src': ['self'], 'script-src': ['self', 'strict-dynamic'], 'style-src': ['self', 'unsafe-inline'], // tighten after audit 'img-src': ['self', 'data:', 'blob:'], 'connect-src':['self'], 'frame-ancestors': ['none'], 'base-uri': ['self'], 'form-action': ['self'] }, reportOnly: { 'report-uri': ['/csp-report'] } } } ``` ### Plus a `securityHeaders` step in `hooks.server.ts` ```typescript const securityHeaders: Handle = async ({ event, resolve }) => { const response = await resolve(event); response.headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'same-origin'); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); return response; }; export const handle = sequence(securityHeaders, userGroup, handleAuth, handleLocaleDetection, handleParaglide); ``` ### Suggested AC - [ ] CSP starts in `reportOnly` mode for one week to surface unintended violations before enforcement. - [ ] `connect-src` allowlists exactly the backend host (e.g., `https://api.familienarchiv.example`); no wildcards. - [ ] TipTap-rendered comment HTML is server-sanitized via `isomorphic-dompurify` before persistence (defense in depth). - [ ] Test: open `/documents` in Chrome with DevTools → Console; zero CSP violations on a clean load. - [ ] HSTS only set when `Forwarded: proto=https` (don't set on bare HTTP staging). This is the chained-vulnerability mitigator for **F-13** — without CSP, an XSS in TipTap content directly exfiltrates the cookie holding `Basic <base64(email:password)>`. Tracked in audit doc as **F-01** (Critical). See `docs/audits/2026-05-07-pre-prod-architectural-review.md`.
Sign in to join this conversation.
No Label security
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#116