3 Commits

Author SHA1 Message Date
0c30e4bd29 chore: release v1.1.2
All checks were successful
Build / test (push) Successful in 9m27s
Build / build (push) Successful in 27s
Build / release (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:57 -04:00
01f83d25f6 fix: SPA deep-link assets and broken home screen CSS
Three root causes addressed:

1. Added <base href="/"> 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 <noreply@anthropic.com>
2026-03-28 09:52:48 -04:00
79adc365d8 server/server.js — added helmet with CSP configured to allow Google Fonts
All checks were successful
Build / test (push) Successful in 9m29s
Build / release (push) Successful in 1s
Build / build (push) Successful in 32s
Dockerfile — creates a non-root app user and runs the process under it
server/routes.js — tailscale_ip validated against IPv4 regex (empty string still allowed)
index.html — sql.js CDN script tag already removed earlier in this session
2026-03-28 09:20:24 -04:00
8 changed files with 85 additions and 7 deletions

View File

@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm test:*)", "Bash(npm test:*)",
"Bash(npm install:*)" "Bash(npm install:*)",
"Bash(find /c/Users/josh1/Documents/Code/Catalyst -type f \\\\\\(-name *.test.js -o -name *.spec.js -o -name .env* -o -name *.config.js \\\\\\))"
] ]
} }
} }

View File

@@ -1,4 +1,6 @@
FROM node:lts-alpine FROM node:lts-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
@@ -8,5 +10,8 @@ COPY . .
RUN awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \ RUN awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \
package.json > js/version.js package.json > js/version.js
RUN chown -R app:app /app
USER app
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server/server.js"] CMD ["node", "server/server.js"]

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Catalyst</title> <title>Catalyst</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -176,7 +177,6 @@
<span id="toast-msg"></span> <span id="toast-msg"></span>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js"></script>
<script src="js/version.js" onerror="window.VERSION=null"></script> <script src="js/version.js" onerror="window.VERSION=null"></script>
<script src="js/config.js"></script> <script src="js/config.js"></script>
<script src="js/db.js"></script> <script src="js/db.js"></script>

16
package-lock.json generated
View File

@@ -1,14 +1,15 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.0.3", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "catalyst", "name": "catalyst",
"version": "1.0.3", "version": "1.1.0",
"dependencies": { "dependencies": {
"express": "^4.18.0" "express": "^4.18.0",
"helmet": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
@@ -1958,6 +1959,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.1.0", "version": "1.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",
@@ -9,7 +9,8 @@
"version:write": "node -e \"const {version}=JSON.parse(require('fs').readFileSync('package.json','utf8'));require('fs').writeFileSync('js/version.js','const VERSION = \\\"'+version+'\\\";\\n');\"" "version:write": "node -e \"const {version}=JSON.parse(require('fs').readFileSync('package.json','utf8'));require('fs').writeFileSync('js/version.js','const VERSION = \\\"'+version+'\\\";\\n');\""
}, },
"dependencies": { "dependencies": {
"express": "^4.18.0" "express": "^4.18.0",
"helmet": "^8.1.0"
}, },
"devDependencies": { "devDependencies": {
"jsdom": "^25.0.0", "jsdom": "^25.0.0",

View File

@@ -22,6 +22,9 @@ function validate(body) {
errors.push(`state must be one of: ${VALID_STATES.join(', ')}`); errors.push(`state must be one of: ${VALID_STATES.join(', ')}`);
if (!VALID_STACKS.includes(body.stack)) if (!VALID_STACKS.includes(body.stack))
errors.push(`stack must be one of: ${VALID_STACKS.join(', ')}`); errors.push(`stack must be one of: ${VALID_STACKS.join(', ')}`);
const ip = (body.tailscale_ip ?? '').trim();
if (ip && !/^(\d{1,3}\.){3}\d{1,3}$/.test(ip))
errors.push('tailscale_ip must be a valid IPv4 address or empty');
return errors; return errors;
} }

View File

@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import helmet from 'helmet';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { router } from './routes.js'; import { router } from './routes.js';
@@ -8,6 +9,23 @@ const PORT = process.env.PORT ?? 3000;
export const app = express(); export const app = express();
app.use(helmet({
contentSecurityPolicy: {
useDefaults: false, // explicit — upgrade-insecure-requests breaks HTTP deployments
directives: {
'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'],
},
},
}));
app.use(express.json()); app.use(express.json());
// API // API

View File

@@ -237,3 +237,43 @@ describe('DELETE /api/instances/:vmid', () => {
expect(res.status).toBe(400) 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('<base href="/">')
})
})