Skip to content

Commit c29ad45

Browse files
committed
feat(cli): kind:'react' pages are off unless the deployment turns them on
WIP: console serving reads OS_PAGE_REACT_ENABLED (legacy OS_PAGE_REACT), defaults the tier off, and writes the capability global in both directions. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc
1 parent f0cf338 commit c29ad45

2 files changed

Lines changed: 118 additions & 36 deletions

File tree

‎packages/cli/src/utils/console.content-security-policy.test.ts‎

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const SHELL = [
3939

4040
/** The environment this suite sets; each test starts with all of it unset and leaves it as it found it. */
4141
const ENV = [
42+
'OS_PAGE_REACT_ENABLED',
4243
'OS_PAGE_REACT',
4344
'OS_CONSOLE_CSP_CONNECT_SRC',
4445
'OS_CLOUD_URL',
@@ -73,10 +74,18 @@ afterEach(() => {
7374
}
7475
});
7576

76-
async function mount(opts: { runtimeConfig?: ConstructorParameters<typeof RuntimeConfigPlugin>[0] } = {}) {
77+
async function mount(opts: {
78+
runtimeConfig?: ConstructorParameters<typeof RuntimeConfigPlugin>[0];
79+
warnings?: string[];
80+
} = {}) {
7781
const server = new HonoHttpServer(0);
7882
server.installNotFoundSeam();
79-
const logger = { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} };
83+
const logger = {
84+
warn: (message: string) => { opts.warnings?.push(message); },
85+
info: () => {},
86+
debug: () => {},
87+
error: () => {},
88+
};
8089
if (opts.runtimeConfig) {
8190
const hooks: Array<() => Promise<void>> = [];
8291
await new RuntimeConfigPlugin(opts.runtimeConfig).start({
@@ -140,10 +149,11 @@ describe('the console document is served with a nonce-based content-security-pol
140149
const nonce = headerNonce(res.headers.get('content-security-policy'));
141150
const html = await res.text();
142151

152+
// The build's three, and the server's react-pages capability global.
143153
const tags = scriptTags(html);
144-
expect(tags).toHaveLength(3);
154+
expect(tags).toHaveLength(4);
145155
for (const tag of tags) expect(tag).toContain(` nonce="${nonce}"`);
146-
expect(html.match(/ nonce="/g)).toHaveLength(3);
156+
expect(html.match(/ nonce="/g)).toHaveLength(4);
147157
});
148158

149159
it('draws a fresh nonce for every response', async () => {
@@ -175,26 +185,57 @@ describe('the console document is served with a nonce-based content-security-pol
175185
});
176186
});
177187

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();
188+
describe('\'unsafe-eval\' follows the react-pages tier, which is off unless the deployment turns it on', () => {
189+
/** The served document's policy, its nonce, and its html. */
190+
async function serve(warnings?: string[]) {
191+
const request = await mount({ warnings });
181192
const res = await request('/_console/');
182-
expect(directives(res.headers.get('content-security-policy')).get('script-src')).toContain("'unsafe-eval'");
193+
const policy = res.headers.get('content-security-policy');
194+
return { scriptSrc: directives(policy).get('script-src')!, nonce: headerNonce(policy), html: await res.text() };
195+
}
196+
197+
it('is refused by default, and the document carries the disable global under the nonce', async () => {
198+
const { scriptSrc, nonce, html } = await serve();
199+
expect(scriptSrc).not.toContain("'unsafe-eval'");
200+
expect(html).toContain(
201+
`<script nonce="${nonce}">window.__OBJECTUI_CAPABILITIES_DISABLED__=(window.__OBJECTUI_CAPABILITIES_DISABLED__||[]).concat('react-pages');</script>`,
202+
);
203+
expect(html).not.toContain('window.__OBJECTUI_CAPABILITIES__=');
183204
});
184205

185-
it('is refused with OS_PAGE_REACT=off, and the injected disable global carries the nonce', async () => {
206+
it('is allowed with OS_PAGE_REACT_ENABLED=true, and the document carries the enable global under the nonce', async () => {
207+
process.env.OS_PAGE_REACT_ENABLED = 'true';
208+
const { scriptSrc, nonce, html } = await serve();
209+
expect(scriptSrc).toContain("'unsafe-eval'");
210+
expect(html).toContain(
211+
`<script nonce="${nonce}">window.__OBJECTUI_CAPABILITIES__=(window.__OBJECTUI_CAPABILITIES__||[]).concat('react-pages');</script>`,
212+
);
213+
expect(html).not.toContain('__OBJECTUI_CAPABILITIES_DISABLED__');
214+
});
215+
216+
it('reads the legacy OS_PAGE_REACT in both directions while the new name is unset', async () => {
217+
process.env.OS_PAGE_REACT = 'on';
218+
expect((await serve()).scriptSrc).toContain("'unsafe-eval'");
186219
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'");
220+
expect((await serve()).scriptSrc).not.toContain("'unsafe-eval'");
221+
});
191222

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__=`);
223+
it('lets the new name win over the legacy one', async () => {
224+
process.env.OS_PAGE_REACT = 'on';
225+
process.env.OS_PAGE_REACT_ENABLED = 'false';
226+
expect((await serve()).scriptSrc).not.toContain("'unsafe-eval'");
227+
});
228+
229+
it('keeps the tier off on a value that is neither on nor off, and says so once', async () => {
230+
process.env.OS_PAGE_REACT_ENABLED = 'ture';
231+
const warnings: string[] = [];
232+
const request = await mount({ warnings });
233+
for (let i = 0; i < 2; i += 1) {
234+
const res = await request('/_console/');
235+
expect(directives(res.headers.get('content-security-policy')).get('script-src')).not.toContain("'unsafe-eval'");
236+
}
237+
const named = warnings.filter((w) => w.includes('OS_PAGE_REACT_ENABLED="ture"'));
238+
expect(named).toHaveLength(1);
198239
});
199240
});
200241

‎packages/cli/src/utils/console.ts‎

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import fs from 'fs';
4848
import { createHash, randomBytes } from 'crypto';
4949
import { createRequire } from 'module';
5050
import { pathToFileURL, fileURLToPath } from 'url';
51+
import { readEnvWithDeprecation } from '@objectstack/types';
5152

5253
// ─── Constants ──────────────────────────────────────────────────────
5354

@@ -733,7 +734,7 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
733734
* Console keeps its session token where any script on the page can read it,
734735
* so only the build's own files and the document's own inline scripts (the
735736
* early-branding, process-polyfill, crypto-shim and boot-splash scripts, and
736-
* the server's react-pages disable global) may run: a script element that
737+
* the server's react-pages capability global) may run: a script element that
737738
* markup injected later carries no nonce, and an inline event handler never
738739
* matches one. No public CDN is listed. A CDN host in `script-src` admits
739740
* every file that CDN serves, so the Studio's Monaco editor, which loads its
@@ -743,7 +744,8 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
743744
* whose grammar engine is WebAssembly compiled in the page. It permits
744745
* compiling WebAssembly and nothing else; JavaScript `eval` stays refused.
745746
* - `'unsafe-eval'` only while the `kind:'react'` page tier is on (ADR-0081;
746-
* on unless `OS_PAGE_REACT=off`). That runtime evaluates the author's
747+
* off unless `OS_PAGE_REACT_ENABLED=true`, `readReactPagesTier`). That
748+
* runtime evaluates the author's
747749
* transpiled page source with `Function`, which nothing but `'unsafe-eval'`
748750
* permits. With the tier off, the policy refuses it.
749751
* - `style-src 'self' 'unsafe-inline'`: the document's own `<style>` (the boot
@@ -793,6 +795,43 @@ function consoleContentSecurityPolicy(input: {
793795
return directives.map((directive) => directive.join(' ')).join('; ');
794796
}
795797

798+
/** The variable a deployment sets to turn the `kind:'react'` page tier on. */
799+
export const REACT_PAGES_ENV = 'OS_PAGE_REACT_ENABLED';
800+
/** Its earlier name, read when the new one is unset (`readEnvWithDeprecation`). */
801+
const REACT_PAGES_LEGACY_ENV = 'OS_PAGE_REACT';
802+
const REACT_PAGES_ON = new Set(['true', '1', 'on', 'yes', 'enabled']);
803+
const REACT_PAGES_OFF = new Set(['', 'false', '0', 'off', 'no', 'disabled']);
804+
805+
/**
806+
* Whether this deployment serves the `kind:'react'` page tier (ADR-0081, as
807+
* amended: off by default). That tier runs the page author's code in every
808+
* viewer's Console with no sandbox, so it is on only when the operator says so
809+
* with `OS_PAGE_REACT_ENABLED=true` (or the legacy `OS_PAGE_REACT=on`). A value
810+
* that is neither an on nor an off spelling leaves the tier off and is reported
811+
* through `onUnrecognized`, so a typo never reads as a silent refusal.
812+
*/
813+
export function readReactPagesTier(onUnrecognized?: (name: string, value: string) => void): boolean {
814+
const raw = readEnvWithDeprecation(REACT_PAGES_ENV, REACT_PAGES_LEGACY_ENV);
815+
const value = String(raw ?? '').trim().toLowerCase();
816+
if (REACT_PAGES_ON.has(value)) return true;
817+
if (!REACT_PAGES_OFF.has(value)) {
818+
const name = process.env[REACT_PAGES_ENV] !== undefined ? REACT_PAGES_ENV : REACT_PAGES_LEGACY_ENV;
819+
onUnrecognized?.(name, String(raw));
820+
}
821+
return false;
822+
}
823+
824+
/**
825+
* The inline script that tells the Console's capability gate (objectui
826+
* `@object-ui/core`, `isCapabilityEnabled`) which way this deployment set the
827+
* react-pages tier. The server writes it in both directions, so its answer
828+
* holds whichever default the Console build carries.
829+
*/
830+
function reactPagesCapabilityScript(reactPages: boolean): string {
831+
const list = reactPages ? '__OBJECTUI_CAPABILITIES__' : '__OBJECTUI_CAPABILITIES_DISABLED__';
832+
return `<script>window.${list}=(window.${list}||[]).concat('react-pages');</script>`;
833+
}
834+
796835
/**
797836
* Every `<script>` start tag in `html` with `nonce` added, unless it already
798837
* carries one. Only start tags match: `</script>` and escaped text do not.
@@ -863,14 +902,19 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
863902
}
864903

865904
// The `kind:'react'` page tier (executes author JS in the main React
866-
// tree) is ON by default. A deployment that does not trust its page
867-
// authors turns it off with `OS_PAGE_REACT=off`; we then inject the
868-
// disable global the console's capability gate reads. Read per request
869-
// (env can change without a rebuild — index.html is re-read on every
870-
// fallback hit too).
871-
const reactPagesDisabled = (): boolean => {
872-
const v = String(process.env.OS_PAGE_REACT ?? '').trim().toLowerCase();
873-
return v === 'off' || v === '0' || v === 'false' || v === 'no' || v === 'disabled';
905+
// tree) is OFF unless the operator turns it on (`readReactPagesTier`);
906+
// either way the document carries the global the console's capability
907+
// gate reads. Read per request (env can change without a rebuild —
908+
// index.html is re-read on every fallback hit too).
909+
let reportedUnrecognizedReactPages = false;
910+
const reportUnrecognizedReactPages = (name: string, value: string) => {
911+
if (reportedUnrecognizedReactPages) return;
912+
reportedUnrecognizedReactPages = true;
913+
ctx.logger?.warn?.(
914+
`Console static: ${name}=${JSON.stringify(value)} is not an on or off value, so kind:'react' pages `
915+
+ `stay off and render a notice instead of running. Set ${REACT_PAGES_ENV}=true to turn them on, or `
916+
+ 'unset it to keep them off. Reported once.',
917+
);
874918
};
875919

876920
// A runtime-config read that fails leaves the shell's head as the build
@@ -904,11 +948,8 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
904948
const baseTag = `<base href="${CONSOLE_PATH}/">`;
905949
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${baseTag}`);
906950
}
907-
if (!reactPages) {
908-
const capTag =
909-
`<script>window.__OBJECTUI_CAPABILITIES_DISABLED__=(window.__OBJECTUI_CAPABILITIES_DISABLED__||[]).concat('react-pages');</script>`;
910-
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${capTag}`);
911-
}
951+
const capTag = reactPagesCapabilityScript(reactPages);
952+
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${capTag}`);
912953
// Stamped while the document holds only the build's markup and the
913954
// server's own tags: nothing read at request time is written yet, so
914955
// no runtime value can ever carry the nonce.
@@ -917,9 +958,9 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
917958

918959
const serveDocument = async (c: any, reqPath: string): Promise<Response> => {
919960
const nonce = randomBytes(16).toString('base64');
920-
// One read per document, so the injected disable global and the
961+
// One read per document, so the injected capability global and the
921962
// policy's `'unsafe-eval'` always agree.
922-
const reactPages = !reactPagesDisabled();
963+
const reactPages = readReactPagesTier(reportUnrecognizedReactPages);
923964
const html = readIndexHtml(reactPages, nonce);
924965
const shell = await readShellConfig(app, c, reportShellConfigReadFailure);
925966
const policy = consoleContentSecurityPolicy({

0 commit comments

Comments
 (0)