Skip to content

Commit 3c30afd

Browse files
feat(cli): serve the Console document with a nonce-based content-security-policy (#25569)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoBVXzK6k7CamM6jB1PW7zzb)_ ## Summary The Console document (every HTML answer under `/_console/`) is now served with a nonce-based `content-security-policy` response header. Until now only API responses carried a policy (`packages/runtime/src/security/security-headers.ts`), and the Console shell carried none. - `createConsoleStaticPlugin` (`packages/cli/src/utils/console.ts`) draws a fresh 128-bit nonce per response. It names the nonce in the header and stamps it on every script element of the served document: the build's inline scripts, its module entry, and the server's own react-pages disable script. - The nonce is stamped before any request-time value (branding title and meta tags) is written into the document, so no runtime value can carry it. - This plugin is the one place the document is served from. `os dev` and `os start` both spawn `os serve`, which mounts it, and the Docker image's command is `os start`. A host that mounts the plugin itself through `@objectstack/cli/console` gets the same header. - Runtime is unchanged. Clause-②: no Metadata-protocol: none ## The policy Each allowance was measured against the published `@objectstack/console@18.0.0` build (its `index.html`, its `assets/`) and the objectui source at the pinned `.objectui-sha` (`2958f17e`). The reason for each is written next to the policy in the code. | Directive | Value | Why | |:--|:--|:--| | `default-src` | `'self'` | Covers maplibre's module workers (started from its own file), the PWA manifest and same-origin frames (the Studio live canvas). The build ships no web fonts. | | `script-src` | `'self' 'nonce-…' 'wasm-unsafe-eval'`, plus `'unsafe-eval'` only while the react-pages tier is on | `'wasm-unsafe-eval'` is for shiki (the AI chat's code highlighter), which compiles WebAssembly. The `kind:'react'` runtime (ADR-0081) evaluates page source with `Function`, so `'unsafe-eval'` follows `OS_PAGE_REACT`. That flag is read once per document, so the disable script and the policy always agree. | | `style-src` | `'self' 'unsafe-inline'` | The shell's boot-splash style element, React/Radix `style` attributes, and style elements libraries insert at run time without a nonce. | | `img-src` | `'self' data: blob: https:` | Inline SVG icons in the stylesheets, upload previews, and externally hosted branding logos and favicons, avatars, image field values and email-template images. | | `connect-src` | `'self' https://demotiles.maplibre.org`, plus the error-reporting DSN's origin when the runtime serves one, plus `OS_CONSOLE_CSP_CONNECT_SRC` | The API (`'self'` also covers the same-origin WebSocket and event stream), and the map view's default style host. The DSN origin is read from the same `GET /api/v1/runtime/config` answer the document's branding is already read from. | | `object-src` | `'none'` | | | `base-uri` | `'self'` | Only the server's own base element. | | `frame-ancestors` | `'self'`, omitted on `/_console/f/*` | The public-forms screen gives authors an iframe snippet that embeds the public form page on their own site. | ### What an operator may notice - **New `OS_CONSOLE_CSP_CONNECT_SRC`.** It is a space-separated list of extra `connect-src` sources: a scheme (`https:`) or a scheme with a host (`https://tiles.example.com`). It exists because a map view's authored `mapStyle` is fetched straight from its host, and the server cannot know that host ahead of the request. It is read once at boot. An entry outside that grammar (a keyword, `*`, a bare host, or anything carrying `;` or quotes) stops the boot with a message naming it. - **Framing from another origin is refused**, except on the public form page. - **The Studio's code editor** (Monaco) loads its core from a public CDN. The policy deliberately lists no CDN in `script-src`, because a CDN host there admits every file that CDN serves. The editor therefore shows the plain-text fallback objectui already ships for this case. ## Verification Tests: a new suite, `packages/cli/src/utils/console.content-security-policy.test.ts`, with 17 cases. It boots the real plugin on a real Hono app, and adds the real `RuntimeConfigPlugin` for the DSN case. It checks: - the header is present on the shell, on an SPA fallback and on a direct `.html` hit, and absent on assets; - the nonce differs across 5 responses; - every script element in the served document carries the header's nonce, including an uppercase tag and the injected react-pages disable script; - `'unsafe-eval'` is present by default and absent with `OS_PAGE_REACT=off`; - `frame-ancestors` is omitted on `/_console/f/*` and kept on `/_console/forms/*`; - the DSN origin appears without its key; - `OS_CONSOLE_CSP_CONNECT_SRC` extras are added in order, and five malformed values refuse the boot. Local results, at head `9182f4b9`: - The full `@objectstack/cli` unit tier (`vitest run --project unit`) passes: 328 files, 4829 tests. The integration tier is left to CI, because the diff touches no spawn entry. - `vitest run --project unit` passes on the new suite plus `console.branded-head`, `console.public-form-redirect`, `console.asset-cache`, `console.not-built`, `console.sha-drift`, `published-subpath-console.pin` and `runtime-assets`. The existing suites pass unchanged. - `pnpm --filter @objectstack/cli typecheck` (tsc plus `check:test-typecheck`) exits 0. `eslint --no-inline-config` on both touched files exits 0. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 69 commands, and all 69 exit 0. The `--ran` reconciliation reports 69 derived, 69 run, 0 not measured. Browser smoke: `pnpm dev -- --fresh -p <port>` (the showcase), serving the published `@objectstack/console@18.0.0` dist, driven by Playwright Chromium. - The run signed in as the seeded admin and visited eight routes: home, the showcase app, a `kind:'react'` page (Task Desk), a map page, the component gallery, the capability map, `/ai`, and a public form URL. - A `securitypolicyviolation` listener was installed before any page script ran, and console messages were captured too. - A negative control at the end of each run proved the listener can see a refusal. It inserts an inline script without a nonce, and a script from the Monaco CDN path. Both were refused and both were reported. | Run | Header on the wire | Violations on the 8 routes | Page errors | |:--|:--|:--|:--| | react pages **on** (default) | `'unsafe-eval'` present | **0** | 0 | | `OS_PAGE_REACT=off` | `'unsafe-eval'` absent | 1 per document load, all the same one (see Acceptance notes) | 0 | In both runs the map rendered its canvas. The Task Desk react page rendered with the tier on, and showed the disabled notice with it off. `frame-ancestors` was absent only on the public form URL. ## Acceptance notes - **With `OS_PAGE_REACT=off`, each document load reports one `script-src` `eval` refusal from the Console bundle.** The source is zod v4's capability probe: it tries `Function('')` once inside a try/catch, to decide whether its JIT can be used, and falls back when refused. Nothing breaks, and the page reports no error. The probe is skipped when zod's `jitless` config is set, so the clean fix is objectui-side: set `z.config({ jitless: true })` in the Console, at least when react pages are off. That is outside this repository and is reported to the maintainer rather than worked around here, for example by allowing `'unsafe-eval'`. - **Monaco.** The code editor's CDN load is refused by design, as above. Self-hosting Monaco in objectui would restore the full editor under this policy. - **Out of scope.** Where the Console keeps its session token is a separate decision, and this PR does not change it. --- _Generated by [Claude Code](https://claude.ai/code/session_013zqbSrrxA8RC7X5JQ4VHLV)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013zqbSrrxA8RC7X5JQ4VHLV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5874d25 commit 3c30afd

4 files changed

Lines changed: 498 additions & 28 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat: the Console document is served with a nonce-based content-security-policy
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Every HTML answer under `/_console/` (`os serve`, and so `os dev`, `os start` and the Docker image, which all run it) now carries a `content-security-policy` header. Each response draws a fresh nonce, and every `<script>` the Console build ships, plus the server's own react-pages disable script, is stamped with it. Other scripts may load only from the server's own origin.
11+
12+
The policy, and why each part is there:
13+
14+
- `script-src 'self' 'nonce-…' 'wasm-unsafe-eval'`. WebAssembly compilation is for the AI chat's code highlighter. `'unsafe-eval'` is added only while the `kind:'react'` page tier is on (ADR-0081, on unless `OS_PAGE_REACT=off`), because that runtime evaluates page source.
15+
- `style-src 'self' 'unsafe-inline'`, for inline styles written by the UI libraries at run time.
16+
- `img-src 'self' data: blob: https:`, for branding, avatars and image fields hosted elsewhere.
17+
- `connect-src 'self'`, plus the default map style's host, plus the error-reporting endpoint's origin when the runtime serves a DSN.
18+
- `object-src 'none'`, `base-uri 'self'`, and `frame-ancestors 'self'`. The public form page (`/_console/f/<slug>`) is the exception to `frame-ancestors`, because authors embed it on other sites.
19+
20+
What an operator may notice:
21+
22+
- **A map view whose `mapStyle` names another host** is refused that host until the operator lists it. Name it in the new `OS_CONSOLE_CSP_CONNECT_SRC`, a space-separated list of sources such as `https://tiles.example.com`. A malformed entry stops the server at boot with a message naming it.
23+
- **Framing the Console from another origin is refused.** The public form page is the exception.
24+
- **The Studio's code editor** loads its editor core from a public CDN, which the policy does not list. It shows its built-in plain-text editor instead.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
2828
| `OS_BOOT_EMPTY` | flag | `0` | When `1`, boot the kernel with no metadata artifact (CLI internal). |
2929
| `OS_MIGRATE_AND_EXIT` | flag | `0` | When `1`, run pending migrations and exit (suitable for one-shot jobs). |
3030
| `OS_DISABLE_CONSOLE` | flag | `0` | When `1`, do not mount the Console portal under `/_console`. |
31+
| `OS_CONSOLE_CSP_CONNECT_SRC` | list | — | Extra `connect-src` sources for the Console document's content-security-policy, separated by spaces: a scheme (`https:`) or a scheme with a host (`https://tiles.example.com`). Name the host of any map view `mapStyle` that is not the default demo style; the server itself, the default map style host and a configured `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN` origin are already allowed. A malformed entry refuses the boot. |
3132
| `OS_EAGER_SCHEMAS` | flag | `0` | When `1`, evaluate all lazily-built Zod spec schemas eagerly at module load instead of on first use — an emergency rollback of the lazy-schema memory optimization (higher startup RSS). |
3233
| `OS_SKIP_SCHEMA_SYNC` | flag | `0` | When `1`, skip the implicit `db:sync` on boot. Use after running migrations manually. |
3334

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The console document's `content-security-policy`.
5+
*
6+
* The Console keeps its session token where any script on the page can read
7+
* it, so the document is served with a nonce-based `script-src`: a fresh nonce
8+
* per response, named in the header and stamped on every `<script>` the
9+
* document carries. These cases run the REAL plugin on a REAL Hono app (the
10+
* `HonoHttpServer` production resolves as `http.server`); the error-reporting
11+
* case adds the REAL `RuntimeConfigPlugin`, so the `connect-src` follows what
12+
* that door serves.
13+
*/
14+
15+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
16+
import fs from 'node:fs';
17+
import os from 'node:os';
18+
import path from 'node:path';
19+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
20+
import { RuntimeConfigPlugin } from '@objectstack/cloud-connection';
21+
22+
import { createConsoleStaticPlugin } from './console.js';
23+
24+
const ORIGIN = 'http://csp.example.test';
25+
26+
/**
27+
* The shell's script shapes, as objectui ships them: classic inline scripts in
28+
* the head, one of them mentioning a closing tag inside a string, and the
29+
* module entry with attributes.
30+
*/
31+
const SHELL = [
32+
'<!doctype html><html><head><meta charset="UTF-8"><title></title>',
33+
'<script>window.early = 1;</script>',
34+
'<SCRIPT>window.shim = "</" + "script>";</SCRIPT>',
35+
'<style>#boot-splash{display:flex}</style>',
36+
'<script type="module" crossorigin src="./assets/index-BQWjE1TW.js"></script>',
37+
'</head><body><div id="root"></div></body></html>',
38+
].join('\n');
39+
40+
/** The environment this suite sets; each test starts with all of it unset and leaves it as it found it. */
41+
const ENV = [
42+
'OS_PAGE_REACT',
43+
'OS_CONSOLE_CSP_CONNECT_SRC',
44+
'OS_CLOUD_URL',
45+
'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN',
46+
];
47+
48+
let saved: Record<string, string | undefined>;
49+
let consoleRoot: string;
50+
let distPath: string;
51+
52+
beforeAll(() => {
53+
consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-csp-'));
54+
distPath = path.join(consoleRoot, 'dist');
55+
fs.mkdirSync(path.join(distPath, 'assets'), { recursive: true });
56+
fs.writeFileSync(path.join(distPath, 'index.html'), SHELL);
57+
fs.writeFileSync(path.join(distPath, 'assets', 'index-BQWjE1TW.js'), 'export {};');
58+
});
59+
60+
afterAll(() => {
61+
fs.rmSync(consoleRoot, { recursive: true, force: true });
62+
});
63+
64+
beforeEach(() => {
65+
saved = Object.fromEntries(ENV.map((name) => [name, process.env[name]]));
66+
for (const name of ENV) delete process.env[name];
67+
});
68+
69+
afterEach(() => {
70+
for (const name of ENV) {
71+
if (saved[name] === undefined) delete process.env[name];
72+
else process.env[name] = saved[name];
73+
}
74+
});
75+
76+
async function mount(opts: { runtimeConfig?: ConstructorParameters<typeof RuntimeConfigPlugin>[0] } = {}) {
77+
const server = new HonoHttpServer(0);
78+
server.installNotFoundSeam();
79+
const logger = { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} };
80+
if (opts.runtimeConfig) {
81+
const hooks: Array<() => Promise<void>> = [];
82+
await new RuntimeConfigPlugin(opts.runtimeConfig).start({
83+
hook: (name: string, fn: () => Promise<void>) => { if (name === 'kernel:ready') hooks.push(fn); },
84+
getService: (name: string) => {
85+
if (name === 'http.server') return server;
86+
throw new Error(`no service ${name}`);
87+
},
88+
logger,
89+
} as any);
90+
for (const fn of hooks) await fn();
91+
}
92+
await createConsoleStaticPlugin(distPath).start({ getServiceAsync: async () => server, logger });
93+
const app = server.getRawApp();
94+
return async (p: string, init?: RequestInit): Promise<Response> => app.request(`${ORIGIN}${p}`, init);
95+
}
96+
97+
/** The policy's directives by name; a directive named twice fails. */
98+
function directives(policy: string | null): Map<string, string[]> {
99+
expect(policy, 'content-security-policy header').toBeTruthy();
100+
const map = new Map<string, string[]>();
101+
for (const part of policy!.split(';')) {
102+
const [name, ...sources] = part.trim().split(/\s+/);
103+
expect(map.has(name), `${name} named twice`).toBe(false);
104+
map.set(name, sources);
105+
}
106+
return map;
107+
}
108+
109+
/** The nonce `script-src` names; exactly one. */
110+
function headerNonce(policy: string | null): string {
111+
const nonces = directives(policy).get('script-src')!.filter((s) => s.startsWith("'nonce-"));
112+
expect(nonces).toHaveLength(1);
113+
return nonces[0].slice("'nonce-".length, -1);
114+
}
115+
116+
/** Every `<script` start tag in `html`, whatever its case. */
117+
function scriptTags(html: string): string[] {
118+
return [...html.matchAll(/<script\b[^>]*>/gi)].map((m) => m[0]);
119+
}
120+
121+
describe('the console document is served with a nonce-based content-security-policy', () => {
122+
it('names the policy\'s fixed directives on the document', async () => {
123+
const request = await mount();
124+
const res = await request('/_console/');
125+
expect(res.status).toBe(200);
126+
const policy = directives(res.headers.get('content-security-policy'));
127+
128+
expect(policy.get('default-src')).toEqual(["'self'"]);
129+
expect(policy.get('script-src')).toEqual(expect.arrayContaining(["'self'", "'wasm-unsafe-eval'"]));
130+
expect(policy.get('script-src')).not.toContain("'unsafe-inline'");
131+
expect(policy.get('object-src')).toEqual(["'none'"]);
132+
expect(policy.get('base-uri')).toEqual(["'self'"]);
133+
expect(policy.get('frame-ancestors')).toEqual(["'self'"]);
134+
expect(policy.get('connect-src')).toEqual(["'self'", 'https://demotiles.maplibre.org']);
135+
});
136+
137+
it('stamps every <script> in the served document with the nonce the header names', async () => {
138+
const request = await mount();
139+
const res = await request('/_console/');
140+
const nonce = headerNonce(res.headers.get('content-security-policy'));
141+
const html = await res.text();
142+
143+
const tags = scriptTags(html);
144+
expect(tags).toHaveLength(3);
145+
for (const tag of tags) expect(tag).toContain(` nonce="${nonce}"`);
146+
expect(html.match(/ nonce="/g)).toHaveLength(3);
147+
});
148+
149+
it('draws a fresh nonce for every response', async () => {
150+
const request = await mount();
151+
const seen = new Set<string>();
152+
for (let i = 0; i < 5; i += 1) {
153+
const res = await request('/_console/');
154+
const nonce = headerNonce(res.headers.get('content-security-policy'));
155+
expect(await res.text()).toContain(`nonce="${nonce}"`);
156+
seen.add(nonce);
157+
}
158+
expect(seen.size).toBe(5);
159+
});
160+
161+
it('carries the policy on every HTML answer: an SPA fallback and a direct .html hit', async () => {
162+
const request = await mount();
163+
for (const p of ['/_console/apps/crm/record/42', '/_console/index.html']) {
164+
const res = await request(p);
165+
const nonce = headerNonce(res.headers.get('content-security-policy'));
166+
for (const tag of scriptTags(await res.text())) expect(tag, p).toContain(`nonce="${nonce}"`);
167+
}
168+
});
169+
170+
it('leaves the build\'s other files without a document policy', async () => {
171+
const request = await mount();
172+
const res = await request('/_console/assets/index-BQWjE1TW.js');
173+
expect(res.status).toBe(200);
174+
expect(res.headers.get('content-security-policy')).toBeNull();
175+
});
176+
});
177+
178+
describe('\'unsafe-eval\' follows the react-pages tier', () => {
179+
it('is allowed while the tier is on, which is the default', async () => {
180+
const request = await mount();
181+
const res = await request('/_console/');
182+
expect(directives(res.headers.get('content-security-policy')).get('script-src')).toContain("'unsafe-eval'");
183+
});
184+
185+
it('is refused with OS_PAGE_REACT=off, and the injected disable global carries the nonce', async () => {
186+
process.env.OS_PAGE_REACT = 'off';
187+
const request = await mount();
188+
const res = await request('/_console/');
189+
const policy = res.headers.get('content-security-policy');
190+
expect(directives(policy).get('script-src')).not.toContain("'unsafe-eval'");
191+
192+
const nonce = headerNonce(policy);
193+
const html = await res.text();
194+
const tags = scriptTags(html);
195+
expect(tags).toHaveLength(4);
196+
for (const tag of tags) expect(tag).toContain(`nonce="${nonce}"`);
197+
expect(html).toContain(`<script nonce="${nonce}">window.__OBJECTUI_CAPABILITIES_DISABLED__=`);
198+
});
199+
});
200+
201+
describe('frame-ancestors', () => {
202+
it('is omitted on the public form page, which authors embed on their own sites', async () => {
203+
const request = await mount();
204+
const res = await request('/_console/f/contact-us');
205+
const policy = directives(res.headers.get('content-security-policy'));
206+
expect(policy.has('frame-ancestors')).toBe(false);
207+
expect(policy.get('script-src')!.some((s) => s.startsWith("'nonce-"))).toBe(true);
208+
});
209+
210+
it('is kept on a page whose path only begins like it', async () => {
211+
const request = await mount();
212+
const res = await request('/_console/forms/contact-us');
213+
expect(directives(res.headers.get('content-security-policy')).get('frame-ancestors')).toEqual(["'self'"]);
214+
});
215+
});
216+
217+
describe('connect-src', () => {
218+
it('names the origin of the error-reporting DSN the runtime serves, without its key', async () => {
219+
const request = await mount({ runtimeConfig: { clientErrorReporting: { dsn: 'https://abc123@o1.ingest.sentry.io/42' } } });
220+
const served = await (await request('/api/v1/runtime/config')).json();
221+
expect(served.telemetry.errorReporting.dsn).toBe('https://abc123@o1.ingest.sentry.io/42');
222+
223+
const res = await request('/_console/');
224+
const policy = res.headers.get('content-security-policy')!;
225+
expect(directives(policy).get('connect-src')).toEqual([
226+
"'self'",
227+
'https://demotiles.maplibre.org',
228+
'https://o1.ingest.sentry.io',
229+
]);
230+
expect(policy).not.toContain('abc123');
231+
});
232+
233+
it('names no error-reporting origin when the runtime serves no DSN', async () => {
234+
const request = await mount({ runtimeConfig: {} });
235+
const res = await request('/_console/');
236+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
237+
"'self'",
238+
'https://demotiles.maplibre.org',
239+
]);
240+
});
241+
242+
it('adds the sources an operator names in OS_CONSOLE_CSP_CONNECT_SRC', async () => {
243+
process.env.OS_CONSOLE_CSP_CONNECT_SRC = ' https://tiles.example.com\twss://*.example.com:8443/rt/ https: ';
244+
const request = await mount();
245+
const res = await request('/_console/');
246+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
247+
"'self'",
248+
'https://demotiles.maplibre.org',
249+
'https://tiles.example.com',
250+
'wss://*.example.com:8443/rt/',
251+
'https:',
252+
]);
253+
});
254+
255+
it.each([
256+
["'unsafe-inline'"],
257+
['https://tiles.example.com;script-src'],
258+
['*'],
259+
['javascript:'],
260+
['tiles.example.com'],
261+
])('refuses the boot when OS_CONSOLE_CSP_CONNECT_SRC names %j', async (value) => {
262+
process.env.OS_CONSOLE_CSP_CONNECT_SRC = `https://ok.example.com ${value}`;
263+
await expect(mount()).rejects.toThrow(/OS_CONSOLE_CSP_CONNECT_SRC names .*not a connect-src source/);
264+
});
265+
});

0 commit comments

Comments
 (0)