From 01f83d25f6de1746218d9c73de32e20367e1c3e1 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 09:52:48 -0400 Subject: [PATCH] fix: SPA deep-link assets and broken home screen CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes addressed: 1. Added to index.html so all relative asset paths (css/app.css, js/*.js) resolve from the root regardless of the current SPA route. Without this, /instance/117 requested /instance/css/app.css, which hit the SPA fallback and returned HTML; helmet's nosniff then refused it as a stylesheet. 2. Removed upgrade-insecure-requests from the CSP (useDefaults: false). This directive told browsers to upgrade HTTP→HTTPS for every asset request, breaking all resource loading on HTTP-only deployments. 3. Changed script-src-attr from 'none' to 'unsafe-inline' to allow the inline onclick handlers used throughout the UI. Co-Authored-By: Claude Sonnet 4.6 --- index.html | 1 + server/server.js | 14 +++++++++++--- tests/api.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 485c45c..ab3c809 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + Catalyst diff --git a/server/server.js b/server/server.js index b14ab0f..5f83e90 100644 --- a/server/server.js +++ b/server/server.js @@ -11,10 +11,18 @@ export const app = express(); app.use(helmet({ contentSecurityPolicy: { + useDefaults: false, // explicit — upgrade-insecure-requests breaks HTTP deployments directives: { - ...helmet.contentSecurityPolicy.getDefaultDirectives(), - 'style-src': ["'self'", 'https://fonts.googleapis.com'], - 'font-src': ["'self'", 'https://fonts.gstatic.com'], + 'default-src': ["'self'"], + 'base-uri': ["'self'"], + 'font-src': ["'self'", 'https://fonts.gstatic.com'], + 'form-action': ["'self'"], + 'frame-ancestors': ["'self'"], + 'img-src': ["'self'", 'data:'], + 'object-src': ["'none'"], + 'script-src': ["'self'"], + 'script-src-attr': ["'unsafe-inline'"], // allow onclick handlers + 'style-src': ["'self'", 'https://fonts.googleapis.com'], }, }, })); diff --git a/tests/api.test.js b/tests/api.test.js index 79deb87..38138ab 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -237,3 +237,43 @@ describe('DELETE /api/instances/:vmid', () => { expect(res.status).toBe(400) }) }) + +// ── Static assets & SPA routing ─────────────────────────────────────────────── + +describe('static assets and SPA routing', () => { + it('serves index.html at root', async () => { + const res = await request(app).get('/') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/html/) + }) + + it('serves index.html for deep SPA routes (e.g. /instance/117)', async () => { + const res = await request(app).get('/instance/117') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/html/) + }) + + it('serves CSS with correct content-type (not sniffed as HTML)', async () => { + const res = await request(app).get('/css/app.css') + expect(res.status).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/css/) + }) + + it('does not set upgrade-insecure-requests in CSP (HTTP deployments must work)', async () => { + const res = await request(app).get('/') + const csp = res.headers['content-security-policy'] ?? '' + expect(csp).not.toContain('upgrade-insecure-requests') + }) + + it('allows inline event handlers in CSP (onclick attributes)', async () => { + const res = await request(app).get('/') + const csp = res.headers['content-security-policy'] ?? '' + // script-src-attr must not be 'none' — that blocks onclick handlers + expect(csp).not.toContain("script-src-attr 'none'") + }) + + it('index.html contains base href / for correct asset resolution on deep routes', async () => { + const res = await request(app).get('/') + expect(res.text).toContain('') + }) +})