Skip to content

feat: add applyDefaultSecurityHeaders convenience export#4

Merged
Jess Sullivan (Jesssullivan) merged 1 commit into
mainfrom
jess/tin-703-security-headers-convenience
Apr 27, 2026
Merged

feat: add applyDefaultSecurityHeaders convenience export#4
Jess Sullivan (Jesssullivan) merged 1 commit into
mainfrom
jess/tin-703-security-headers-convenience

Conversation

@Jesssullivan

Copy link
Copy Markdown
Contributor

Summary

Closes TIN-703.

Adds a small convenience layer for applying standard security headers to HTTP responses. Three presets covering common deployment postures.

API

import { applyDefaultSecurityHeaders } from '@tummycrypt/tinyland-security';

export async function handle({ event, resolve }) {
  const response = await resolve(event);
  applyDefaultSecurityHeaders(response.headers, { preset: 'strict' });
  return response;
}

Presets

  • strict: CSP frame-ancestors none, COOP/CORP same-origin, locked Permissions-Policy, X-Frame-Options DENY. For admin apps and authenticated surfaces.
  • moderate: relaxes CORP to same-site and X-Frame-Options to SAMEORIGIN.
  • permissive: minimal headers (Referrer-Policy + X-Content-Type-Options) for public read-only surfaces.

Per-header overrides allow strings (set) or null (remove preset default).

Idempotent behavior

applyDefaultSecurityHeaders only sets headers not already present, so callers can override per-route and the preset fills in the rest.

Version bump

0.2.3 → 0.3.0 (minor — new public API, no breaking changes).

Validation

  • pnpm typecheck ✓
  • pnpm test (119/119) ✓
  • pnpm build ✓
  • pnpm check:package (publint) ✓

Consumer migration

After merge + tag:

  • elders.tinyland.dev deletes src/lib/server/security-headers.ts (23 lines) and imports from this package
  • tinyland.dev replaces inline block in hooks.server.ts (~20 lines)

Tracked in TIN-698 (Stage 1 consumer migration).

Closes TIN-703.

Adds a small convenience layer for applying standard security headers to
HTTP responses. Three presets:

- strict: max restriction, suitable for admin apps. CSP frame-ancestors none,
  COOP/CORP same-origin, locked Permissions-Policy, X-Frame-Options DENY.
- moderate: relaxes CORP to same-site and X-Frame-Options to SAMEORIGIN
  for apps that allow same-site framing.
- permissive: minimal headers (Referrer-Policy + X-Content-Type-Options)
  for public read-only surfaces where CSP would block legitimate behavior.

API:
  applyDefaultSecurityHeaders(headers, options?)
  getDefaultSecurityHeaders(options?)
  type SecurityHeaderPreset = 'strict' | 'moderate' | 'permissive'
  type SecurityHeadersOptions = { preset?, overrides?: Record<string, string|null> }

Idempotent: only sets headers that aren't already present, so callers
can override individual headers upstream and still benefit from the rest
of the preset.

Bumps to 0.3.0 (minor — new public API).

Validation: typecheck ✓, test (119/119) ✓, build ✓, publint ✓
@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces applyDefaultSecurityHeaders / getDefaultSecurityHeaders with three presets (strict, moderate, permissive) and bumps the package to 0.3.0. The implementation is clean and the idempotency contract is correct, but the moderate preset contains a logic bug that silently undermines its purpose.

  • P1 — moderate CSP blocks framing despite X-Frame-Options: SAMEORIGIN: MODERATE_HEADERS spreads STRICT_HEADERS without overriding Content-Security-Policy, so it inherits frame-ancestors 'none'. All modern browsers honour CSP frame-ancestors over X-Frame-Options, meaning the preset still blocks all framing — the opposite of what is advertised. The CSP override needs frame-ancestors 'self'.

Confidence Score: 3/5

Not safe to merge as-is — the moderate preset silently ships with frame-ancestors 'none' in CSP, making its framing relaxation a no-op in all modern browsers.

One P1 logic bug in the core feature of the PR: the moderate preset's advertised same-origin framing permission is completely overridden by the inherited CSP frame-ancestors 'none'. The fix is a one-line CSP override in MODERATE_HEADERS.

src/securityHeaders.ts (lines 46–50) and the corresponding test in tests/securityHeaders.test.ts (line 19).

Important Files Changed

Filename Overview
src/securityHeaders.ts New security-header preset module — contains a P1 logic bug where moderate inherits frame-ancestors 'none' from the strict CSP, making the framing relaxation a no-op in all modern browsers.
tests/securityHeaders.test.ts New test file with good coverage, but the moderate preset test only asserts toBeDefined() on the CSP rather than verifying frame-ancestors, missing the bug in securityHeaders.ts.
src/index.ts Adds clean re-exports of applyDefaultSecurityHeaders, getDefaultSecurityHeaders, and the two types from securityHeaders.ts; no issues.
package.json Version bumped from 0.2.3 → 0.3.0 to reflect the new public API addition.
BUILD.bazel Version bump to 0.3.0, consistent with package.json.
MODULE.bazel Version bump to 0.3.0, consistent with other manifests.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["applyDefaultSecurityHeaders(headers, options)"] --> B["getDefaultSecurityHeaders(options)"]
    B --> C{"preset?"}
    C -- strict --> D["STRICT_HEADERS\nframe-ancestors 'none'\nCORP: same-origin\nXFO: DENY"]
    C -- moderate --> E["MODERATE_HEADERS\n⚠️ frame-ancestors 'none' (inherited!)\nCORP: same-site\nXFO: SAMEORIGIN"]
    C -- permissive --> F["PERMISSIVE_HEADERS\nReferrer-Policy only\nX-Content-Type-Options only"]
    D --> G["Apply overrides"]
    E --> G
    F --> G
    G --> H["resolved headers map"]
    H --> I{"headers.has(key)?"}
    I -- No --> J["headers.set(key, value)"]
    I -- Yes --> K["skip (idempotent)"]
Loading

Reviews (1): Last reviewed commit: "feat: add applyDefaultSecurityHeaders co..." | Re-trigger Greptile

Comment thread src/securityHeaders.ts
Comment on lines +46 to +50
const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
...STRICT_HEADERS,
"Cross-Origin-Resource-Policy": "same-site",
"X-Frame-Options": "SAMEORIGIN",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 moderate CSP still contains frame-ancestors 'none', neutralising the framing relaxation

MODERATE_HEADERS spreads STRICT_HEADERS without overriding Content-Security-Policy, so it inherits frame-ancestors 'none'. All modern browsers (Chrome, Firefox, Safari) follow CSP frame-ancestors over X-Frame-Options; the SAMEORIGIN value in this preset is therefore a no-op — framing is still universally blocked, directly contradicting the preset's documented purpose of "allow same-site framing."

The CSP must be overridden to replace frame-ancestors 'none' with frame-ancestors 'self' to match the advertised behaviour.

Suggested change
const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
...STRICT_HEADERS,
"Cross-Origin-Resource-Policy": "same-site",
"X-Frame-Options": "SAMEORIGIN",
});
const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
...STRICT_HEADERS,
"Content-Security-Policy":
"base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'",
"Cross-Origin-Resource-Policy": "same-site",
"X-Frame-Options": "SAMEORIGIN",
});

Comment on lines +15 to +20
it("relaxes X-Frame-Options and CORP for moderate", () => {
const headers = getDefaultSecurityHeaders({ preset: "moderate" });
expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-site");
expect(headers["Content-Security-Policy"]).toBeDefined();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Moderate-preset CSP test doesn't assert frame-ancestors value

The test checks only that Content-Security-Policy is defined, which passes even with frame-ancestors 'none' inherited from STRICT. A targeted assertion on the frame-ancestors token would have caught the bug above. Consider asserting that the value contains frame-ancestors 'self' and does not contain 'none'.

Suggested change
it("relaxes X-Frame-Options and CORP for moderate", () => {
const headers = getDefaultSecurityHeaders({ preset: "moderate" });
expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-site");
expect(headers["Content-Security-Policy"]).toBeDefined();
});
it("relaxes X-Frame-Options and CORP for moderate", () => {
const headers = getDefaultSecurityHeaders({ preset: "moderate" });
expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-site");
expect(headers["Content-Security-Policy"]).toContain("frame-ancestors 'self'");
expect(headers["Content-Security-Policy"]).not.toContain("frame-ancestors 'none'");
});

@Jesssullivan Jess Sullivan (Jesssullivan) merged commit 4827bf8 into main Apr 27, 2026
5 checks passed
@Jesssullivan Jess Sullivan (Jesssullivan) deleted the jess/tin-703-security-headers-convenience branch April 27, 2026 23:20
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