Skip to content

Commit ae7ed0d

Browse files
DavidBabinecclaude
andauthored
fix(security): add base-uri 'self' + object-src 'none' to the admin CSP (CoreBunch#158)
The admin CSP was `frame-ancestors 'none'` only. Add two directives that are safe to enforce today and close two injection vectors as defense-in-depth behind the htmlAttributes/custom-tag hardening: - `base-uri 'self'` — a `<base href>` injection can otherwise rewrite the resolution of every relative URL on the page (script/style/fetch targets). The admin never emits a `<base>` element. - `object-src 'none'` — blocks `<object>` / `<embed>` plugin content. The admin never embeds either. A `script-src` / `style-src` policy is deliberately still NOT set: the admin ships an inline `<script type="importmap">` the plugin runtime needs, the visual-editor canvas is `srcDoc` iframes (which inherit this policy) that inject the site's runtime scripts as inline `<script>`, and the editor relies on inline styles for dynamic custom properties. A safe `script-src` needs per-request nonce plumbing through the served-HTML patcher and the canvas script injector plus a full editor browser sweep — a dedicated follow-up. Tests: security-headers.test.ts updated to assert the three-directive policy on every /admin path and the absence on public/uploads/root. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 75086cf commit ae7ed0d

2 files changed

Lines changed: 42 additions & 14 deletions

File tree

server/securityHeaders.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,23 @@ import { publicOriginIsHttps } from './auth/security'
2222
*
2323
* Admin-specific headers (pathname starts with /admin):
2424
* - `X-Frame-Options: DENY` — blocks framing in legacy browsers.
25-
* - `Content-Security-Policy: frame-ancestors 'none'` — blocks framing in
26-
* modern browsers. Sent alongside X-Frame-Options because frame-ancestors
27-
* takes precedence where supported.
25+
* - `Content-Security-Policy` — three directives that are safe today:
26+
* · `frame-ancestors 'none'` — blocks framing in modern browsers (sent
27+
* alongside X-Frame-Options, which it supersedes where supported).
28+
* · `base-uri 'self'` — blocks a `<base href>` injection from rewriting
29+
* the resolution of every relative URL on the page (the admin never
30+
* emits a `<base>` element).
31+
* · `object-src 'none'` — blocks `<object>` / `<embed>` plugin content
32+
* (the admin never embeds either).
2833
*
29-
* A full admin CSP (default-src, script-src, etc.) is a follow-up task.
30-
* The admin is a React SPA with a blob: canvas iframe and dynamically-loaded
31-
* plugin module bundles; scoping beyond frame-ancestors requires auditing
32-
* every source to avoid breaking the editor.
34+
* A `script-src` / `style-src` policy is deliberately NOT set here yet: the
35+
* admin ships an inline `<script type="importmap">` the plugin runtime needs,
36+
* the visual-editor canvas is `srcDoc` iframes (which inherit this policy)
37+
* that inject the site's runtime scripts as inline `<script>`, and the
38+
* editor relies on inline styles for dynamic custom properties. A safe
39+
* `script-src` therefore requires per-request nonce plumbing through the
40+
* served-HTML patcher and the canvas script injector plus a full editor
41+
* browser sweep — tracked as a dedicated follow-up, not bolted on here.
3342
*
3443
* @param res The raw Response from the route handler.
3544
* @param pathname URL pathname of the incoming request.
@@ -57,7 +66,10 @@ export function applySecurityHeaders(res: Response, pathname: string): Response
5766
// A framed CMS admin is a clickjacking vector for one-click publish/delete.
5867
if (pathname.startsWith('/admin')) {
5968
headers.set('x-frame-options', 'DENY')
60-
headers.set('content-security-policy', "frame-ancestors 'none'")
69+
headers.set(
70+
'content-security-policy',
71+
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
72+
)
6173
}
6274

6375
return new Response(res.body, {

src/__tests__/server/security-headers.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,21 +96,37 @@ describe('applySecurityHeaders — admin framing protection', () => {
9696
expect(res.headers.get('x-frame-options')).toBe('DENY')
9797
})
9898

99-
it("sets CSP frame-ancestors 'none' on /admin HTML responses", () => {
99+
it('sets the admin CSP (frame-ancestors + base-uri + object-src) on /admin HTML responses', () => {
100100
const res = applySecurityHeaders(makeResponse('<html>admin</html>'), '/admin')
101-
expect(res.headers.get('content-security-policy')).toBe("frame-ancestors 'none'")
101+
expect(res.headers.get('content-security-policy')).toBe(
102+
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
103+
)
102104
})
103105

104-
it('applies framing protection to /admin/* subpaths (admin SPA routes)', () => {
106+
it("locks base-uri to 'self' so a <base href> injection can't rewrite relative URLs", () => {
107+
const res = applySecurityHeaders(makeResponse('<html>admin</html>'), '/admin')
108+
expect(res.headers.get('content-security-policy')).toContain("base-uri 'self'")
109+
})
110+
111+
it("blocks <object>/<embed> plugin content with object-src 'none'", () => {
112+
const res = applySecurityHeaders(makeResponse('<html>admin</html>'), '/admin')
113+
expect(res.headers.get('content-security-policy')).toContain("object-src 'none'")
114+
})
115+
116+
it('applies the admin CSP to /admin/* subpaths (admin SPA routes)', () => {
105117
const res = applySecurityHeaders(makeResponse(), '/admin/site/123')
106118
expect(res.headers.get('x-frame-options')).toBe('DENY')
107-
expect(res.headers.get('content-security-policy')).toBe("frame-ancestors 'none'")
119+
expect(res.headers.get('content-security-policy')).toBe(
120+
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
121+
)
108122
})
109123

110-
it('applies framing protection to /admin/api/* (admin API endpoints)', () => {
124+
it('applies the admin CSP to /admin/api/* (admin API endpoints)', () => {
111125
const res = applySecurityHeaders(makeResponse('{}'), '/admin/api/cms/login')
112126
expect(res.headers.get('x-frame-options')).toBe('DENY')
113-
expect(res.headers.get('content-security-policy')).toBe("frame-ancestors 'none'")
127+
expect(res.headers.get('content-security-policy')).toBe(
128+
"frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
129+
)
114130
})
115131

116132
it('does NOT set X-Frame-Options on public page responses', () => {

0 commit comments

Comments
 (0)