From 4539e2e68c9b9014f5700ad1b00018df742cceac Mon Sep 17 00:00:00 2001 From: mdantonio Date: Thu, 1 Oct 2026 22:45:01 +0200 Subject: [PATCH] feat(runtime): scope external resource origins to published scripts --- docs/features/site-shell.md | 3 ++ server/publish/runtime/bundleScripts.ts | 2 + .../code-editor/scriptSettingsPane.test.tsx | 14 +++++++ src/__tests__/publisher/cspPlan.test.ts | 11 +++++ src/__tests__/server/siteRuntimeBuild.test.ts | 31 ++++++++++++++ .../site-runtime/resourceOrigins.test.ts | 19 +++++++++ .../ResourceOriginsControl.module.css | 18 ++++++++ .../code-editor/ResourceOriginsControl.tsx | 42 +++++++++++++++++++ .../site/code-editor/ScriptSettingsPane.tsx | 18 ++++++++ src/core/publisher/render.ts | 14 +++++-- src/core/site-runtime-schema/index.ts | 1 + src/core/site-runtime/assetManifest.ts | 10 +++++ src/core/site-runtime/index.ts | 3 ++ src/core/site-runtime/resourceOrigins.ts | 30 +++++++++++++ src/core/site-runtime/runtimeConfig.ts | 3 ++ src/core/site-runtime/schemas.ts | 10 +++++ 16 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/site-runtime/resourceOrigins.test.ts create mode 100644 src/admin/pages/site/code-editor/ResourceOriginsControl.module.css create mode 100644 src/admin/pages/site/code-editor/ResourceOriginsControl.tsx create mode 100644 src/core/site-runtime/resourceOrigins.ts diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index c0bcc632c..32d350d11 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -342,6 +342,7 @@ type SiteRuntimeConfig = { timing: 'immediate' | 'dom-ready' | 'idle' scope: SiteAssetScope priority: number + resourceOrigins?: { scripts: string[]; frames: string[]; connections: string[] } }> // Per-stylesheet targeting + cascade, keyed by SiteFile id. styles: Record` has no execution model, so stylesheets omit those). Both are edited from the floating code editor's left rail (`ScriptSettingsPane` / `StyleSettingsPane`). +The script settings rail also accepts comma-separated HTTP(S) origins for external scripts, frames and connections. `resourceOrigins` grants those origins in the published page's matching CSP directives, only when that script emits a tag on the page. Disabled, out-of-scope and failed builds grant nothing. Paths, credentials, wildcards and CSP keywords are rejected; origins are normalized and deduplicated. A connection declaration retains same-origin access. Canvas execution remains governed by its own iframe policy; these declarations control published-page CSP. + --- ## Loading the site diff --git a/server/publish/runtime/bundleScripts.ts b/server/publish/runtime/bundleScripts.ts index 8f513e19f..8cc089a98 100644 --- a/server/publish/runtime/bundleScripts.ts +++ b/server/publish/runtime/bundleScripts.ts @@ -150,6 +150,7 @@ function buildClassicRuntimeFiles( placement: script.config.placement, timing: script.config.timing, priority: script.config.priority, + ...(script.config.resourceOrigins ? { resourceOrigins: script.config.resourceOrigins } : {}), }) } @@ -449,6 +450,7 @@ export async function buildSiteRuntimeScripts( placement: script.config.placement, timing: script.config.timing, priority: script.config.priority, + ...(script.config.resourceOrigins ? { resourceOrigins: script.config.resourceOrigins } : {}), } }) .filter((script): script is PublishedRuntimeScriptAsset => script !== null) diff --git a/src/__tests__/code-editor/scriptSettingsPane.test.tsx b/src/__tests__/code-editor/scriptSettingsPane.test.tsx index f91ea4905..df26ce9b7 100644 --- a/src/__tests__/code-editor/scriptSettingsPane.test.tsx +++ b/src/__tests__/code-editor/scriptSettingsPane.test.tsx @@ -47,6 +47,20 @@ function resetStore() { beforeEach(resetStore) describe('Script runtime settings pane', () => { + it('validates origins locally and preserves grants while invalid text is corrected', () => { + render() + const input = screen.getByLabelText('Allowed scripts origins') + fireEvent.change(input, { target: { value: 'https://EXAMPLE.com, https://example.com/' } }) + fireEvent.blur(input) + expect(useEditorStore.getState().siteRuntime.scripts['script-1'].resourceOrigins?.scripts).toEqual(['https://example.com']) + fireEvent.change(input, { target: { value: "'unsafe-inline'" } }) + fireEvent.blur(input) + expect(input.getAttribute('aria-invalid')).toBe('true') + expect(useEditorStore.getState().siteRuntime.scripts['script-1'].resourceOrigins?.scripts).toEqual(['https://example.com']) + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + expect(useEditorStore.getState().siteRuntime.scripts['script-1'].resourceOrigins).toBeUndefined() + }) it('keeps many problems bounded and collapses to the live error count', () => { const diagnostics = Array.from({ length: 12 }, (_, index) => ({ code: `runtime-error-${index}`, diff --git a/src/__tests__/publisher/cspPlan.test.ts b/src/__tests__/publisher/cspPlan.test.ts index e9cfea458..e426300da 100644 --- a/src/__tests__/publisher/cspPlan.test.ts +++ b/src/__tests__/publisher/cspPlan.test.ts @@ -208,6 +208,17 @@ function extractPublishedCsp(html: string): string { } describe('publishPage — CSP frame-src from module cspSources', () => { + it('does not lift frame restrictions for a connection-only script or invalid script URL', () => { + const page = makePage({ root: { moduleId: 'base.text', props: {} } }) + const registry = makeRegistry({ 'base.text': makeModule('base.text') }) + const asset = { fileId: 'forms', src: '/forms.js', placement: 'body-end' as const, timing: 'dom-ready' as const, priority: 1, + resourceOrigins: { scripts: [], frames: [], connections: ['https://forms.example'] } } + const csp = extractPublishedCsp(publishPage(page, makeSite(), registry, { runtimeAssets: { scripts: [asset] } }).html) + expect(csp).toContain("frame-src 'none';") + expect(csp).toContain("connect-src 'self' https://forms.example;") + const rejected = extractPublishedCsp(publishPage(page, makeSite(), registry, { runtimeAssets: { scripts: [{ ...asset, src: 'https://remote.example/script.js' }] } }).html) + expect(rejected).not.toContain('forms.example') + }) it('page with a youtube video has youtube.com in frame-src (not none)', () => { const page = makePage({ root: { diff --git a/src/__tests__/server/siteRuntimeBuild.test.ts b/src/__tests__/server/siteRuntimeBuild.test.ts index a145dddc2..a7c92142b 100644 --- a/src/__tests__/server/siteRuntimeBuild.test.ts +++ b/src/__tests__/server/siteRuntimeBuild.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'bun:test' import type { SiteDocument } from '@core/page-tree' +import { publishPage } from '@core/publisher' import { normalizeSiteRuntimeConfig } from '@core/site-runtime' import { buildRuntimePreviewDocument } from '../../../server/publish/runtime/previewRuntime' import { buildSiteRuntimeScripts } from '../../../server/publish/runtime/bundleScripts' @@ -60,6 +61,36 @@ function runtimeSite(overrides: Partial = {}): SiteDocument { } describe('site runtime build', () => { + for (const format of ['module', 'classic'] as const) { + it(`grants resource origins only for a successful scoped ${format} entry`, async () => { + const site = runtimeSite() + site.files[0]!.content = 'window.runtimeReady = true' + site.runtime.scripts.entry = normalizeSiteRuntimeConfig({ scripts: { entry: { + format, scope: { type: 'pages', pageIds: [page.id] }, + resourceOrigins: { scripts: ['https://challenges.cloudflare.com'], frames: ['https://challenges.cloudflare.com'], connections: ['https://forms.example'] }, + } } }).scripts.entry! + const build = () => buildSiteRuntimeScripts({ site, page, target: 'publish', assetBasePath: '/_runtime' }) + const successful = await build() + const csp = publishPage(page, site, registry, { runtimeAssets: successful.runtimeAssets }).html + expect(csp).toContain('frame-src https://challenges.cloudflare.com;') + expect(csp).toContain("connect-src 'self' https://forms.example;") + expect(csp).toContain("script-src 'self' https://challenges.cloudflare.com;") + + for (const patch of [{ enabled: false }, { enabled: true, scope: { type: 'pages' as const, pageIds: [] } }]) { + Object.assign(site.runtime.scripts.entry!, patch) + const excluded = await build() + expect(excluded.runtimeAssets.scripts).toHaveLength(0) + expect(publishPage(page, site, registry, { runtimeAssets: excluded.runtimeAssets }).html).not.toContain('challenges.cloudflare.com') + } + if (format === 'module') { + site.runtime.scripts.entry!.scope = { type: 'all-pages' } + site.files[0]!.content = 'const broken = ;' + const failed = await build() + expect(failed.diagnostics.some((d) => d.severity === 'error')).toBe(true) + expect(failed.runtimeAssets.scripts).toHaveLength(0) + } + }) + } it('bundles enabled site script entrypoints and returns self-hosted runtime assets', async () => { const result = await buildSiteRuntimeScripts({ site: runtimeSite(), diff --git a/src/__tests__/site-runtime/resourceOrigins.test.ts b/src/__tests__/site-runtime/resourceOrigins.test.ts new file mode 100644 index 000000000..bf3ea4b54 --- /dev/null +++ b/src/__tests__/site-runtime/resourceOrigins.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'bun:test' +import { normalizeResourceOrigins, normalizeScriptRuntimeConfig, parseResourceOrigin } from '@core/site-runtime' + +describe('runtime resource origins', () => { + it('canonicalizes and deduplicates HTTP origins, retaining explicit ports', () => { + expect(normalizeResourceOrigins({ scripts: ['https://EXAMPLE.com/', 'https://example.com', 'http://localhost:3001'], frames: [], connections: [] })) + .toEqual({ scripts: ['http://localhost:3001', 'https://example.com'], frames: [], connections: [] }) + }) + + it('rejects credentials, paths, CSP syntax and encoded authority punctuation', () => { + for (const value of ["'unsafe-inline'", '*', 'https://*.example.com', 'data:text/javascript,x', 'javascript:alert(1)', + '//example.com', 'https://user:pass@example.com', 'https://example.com/a', 'https://example.com/../', + 'https://example.com?x=1', 'https://example.com#x', "https://evil%27.example", 'https://evil%3b.example', + 'https://evil%2a.example', 'https://example.com; script-src *', 'https://example.com\\evil']) { + expect(parseResourceOrigin(value)).toBeNull() + } + expect(normalizeScriptRuntimeConfig({ resourceOrigins: { scripts: ['*'], frames: [], connections: [] } }).resourceOrigins).toBeUndefined() + }) +}) diff --git a/src/admin/pages/site/code-editor/ResourceOriginsControl.module.css b/src/admin/pages/site/code-editor/ResourceOriginsControl.module.css new file mode 100644 index 000000000..3a0073f08 --- /dev/null +++ b/src/admin/pages/site/code-editor/ResourceOriginsControl.module.css @@ -0,0 +1,18 @@ +.field { + display: flex; + flex-direction: column; + gap: var(--space-3xs); +} + +.label { + color: var(--text-subtle); + font-size: var(--text-2xs); + font-weight: 700; + line-height: 1.2; +} + +.validationError { + color: var(--danger-light); + font-size: var(--text-2xs); + line-height: 1.35; +} diff --git a/src/admin/pages/site/code-editor/ResourceOriginsControl.tsx b/src/admin/pages/site/code-editor/ResourceOriginsControl.tsx new file mode 100644 index 000000000..d9324ae6d --- /dev/null +++ b/src/admin/pages/site/code-editor/ResourceOriginsControl.tsx @@ -0,0 +1,42 @@ +import { useId, useState } from 'react' +import { parseResourceOrigin } from '@core/site-runtime' +import { Input } from '@ui/components/Input' +import styles from './ResourceOriginsControl.module.css' + +interface ResourceOriginsControlProps { + label: string + origins: string[] + onChange: (origins: string[]) => void +} + +export function ResourceOriginsControl({ label, origins, onChange }: ResourceOriginsControlProps) { + const id = useId() + const [draft, setDraft] = useState(null) + const [error, setError] = useState(false) + return ( +
+ + { setDraft(event.target.value); setError(false) }} + onBlur={(event) => { + const entries = event.target.value.split(',').map((entry) => entry.trim()).filter(Boolean) + const parsed = entries.map(parseResourceOrigin) + if (parsed.some((origin) => origin === null)) { setError(true); return } + onChange([...new Set(parsed.filter((origin): origin is string => origin !== null))].sort()) + setDraft(null) + setError(false) + }} + /> + {error && + Use HTTP(S) origins without paths or credentials. Separate them with commas. + } +
+ ) +} diff --git a/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx b/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx index e05374ccb..4b6372d54 100644 --- a/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx +++ b/src/admin/pages/site/code-editor/ScriptSettingsPane.tsx @@ -14,6 +14,7 @@ import { Input } from '@ui/components/Input' import { Select } from '@ui/components/Select' import { Switch } from '@ui/components/Switch' import { AssetScopeControl, type ScopePageOption } from './AssetScopeControl' +import { ResourceOriginsControl } from './ResourceOriginsControl' import styles from './ScriptSettingsPane.module.css' interface ScriptSettingsPaneProps { @@ -138,6 +139,23 @@ export function ScriptSettingsPane({ file }: ScriptSettingsPaneProps) { /> +
+ External resources + {(['scripts', 'frames', 'connections'] as const).map((kind) => ( + patch({ resourceOrigins: { + scripts: config.resourceOrigins?.scripts ?? [], + frames: config.resourceOrigins?.frames ?? [], + connections: config.resourceOrigins?.connections ?? [], + [kind]: origins, + } })} + /> + ))} +
+ {(runtimePackages.length > 0 || diagnostics.length > 0) && (
{runtimePackages.length > 0 && ( diff --git a/src/core/publisher/render.ts b/src/core/publisher/render.ts index 2174b9432..e4785a9e4 100644 --- a/src/core/publisher/render.ts +++ b/src/core/publisher/render.ts @@ -38,8 +38,8 @@ import { buildSiteFrameworkCss } from './frameworkCss' import type { SiteCssBundle } from './siteCssBundle' import { escapeHtml, isSafeUrl } from './utils' import { addCspSources, createBaseCspPlan, cspMetaTag } from './cspPlan' -import type { PublishedPageRuntimeAssets } from '@core/site-runtime/schemas' -import { hasPublishedRuntimeScripts, scriptTagsForRuntimeAssets } from '@core/site-runtime' +import type { PublishedPageRuntimeAssets } from '@core/site-runtime-schema' +import { hasPublishedRuntimeScripts, scriptTagsForRuntimeAssets, publishedRuntimeResourceOrigins } from '@core/site-runtime' import { renderNode } from './renderNode' import { findDynamicNodeIds } from './dynamicDetection' import { collectHoleSubtreeModuleIds } from './holeSubtreeModules' @@ -462,8 +462,16 @@ function buildContentSecurityPolicy( anyScriptTag: boolean, importmap: PublishedRuntimePackageImportmap | undefined, moduleCspSources: ReadonlyMap>, + runtimeAssets: PublishedPageRuntimeAssets | undefined, ): string { const plan = createBaseCspPlan({ anyScriptTag, importmapSha: importmap?.sha256 }) + for (const origins of publishedRuntimeResourceOrigins(runtimeAssets)) { + if (origins.scripts.length) addCspSources(plan, 'script-src', origins.scripts) + if (origins.frames.length) addCspSources(plan, 'frame-src', origins.frames) + if (origins.connections.length) { + addCspSources(plan, 'connect-src', ["'self'", ...origins.connections]) + } + } // Merge per-page CSP requirements declared by module render() outputs. // addCspSources automatically drops the lone 'none' when real sources are // added, so frame-src 'none' becomes frame-src on pages that @@ -614,7 +622,7 @@ export function publishPage( const meta = buildDocumentMetaTags(site, page, templateContext, options.documentMeta) const runtime = buildRuntimeAssetsBlock(options, acc) - const csp = buildContentSecurityPolicy(runtime.anyScriptTag, runtime.importmap, acc.cspSources) + const csp = buildContentSecurityPolicy(runtime.anyScriptTag, runtime.importmap, acc.cspSources, options.runtimeAssets) const html = assembleHtmlDocument({ langAttr: meta.langAttr, diff --git a/src/core/site-runtime-schema/index.ts b/src/core/site-runtime-schema/index.ts index b359849fa..0f698315b 100644 --- a/src/core/site-runtime-schema/index.ts +++ b/src/core/site-runtime-schema/index.ts @@ -22,6 +22,7 @@ export type { SiteScriptPlacement, SiteScriptRuntimeConfig, SiteScriptTiming, + SiteScriptResourceOrigins, SiteStyleRuntimeConfig, } from '../site-runtime/schemas' diff --git a/src/core/site-runtime/assetManifest.ts b/src/core/site-runtime/assetManifest.ts index 37740c6d8..87a665728 100644 --- a/src/core/site-runtime/assetManifest.ts +++ b/src/core/site-runtime/assetManifest.ts @@ -3,6 +3,16 @@ import type { PublishedRuntimeScriptAsset, SiteScriptPlacement, } from './schemas' +import { normalizeResourceOrigins } from './resourceOrigins' + +/** Only scripts that actually emit a tag may grant resource origins. */ +export function publishedRuntimeResourceOrigins(runtimeAssets: PublishedPageRuntimeAssets | undefined) { + return (['head', 'body-end'] as const).flatMap((placement) => + runtimeScriptsForPlacement(runtimeAssets, placement).flatMap((asset) => { + const origins = normalizeResourceOrigins(asset.resourceOrigins) + return origins ? [origins] : [] + })) +} function escapeAttribute(value: string): string { return value diff --git a/src/core/site-runtime/index.ts b/src/core/site-runtime/index.ts index 80970b7f7..6edbb7fe2 100644 --- a/src/core/site-runtime/index.ts +++ b/src/core/site-runtime/index.ts @@ -13,6 +13,7 @@ export type { SiteScriptFormat, SiteScriptPlacement, SiteScriptTiming, + SiteScriptResourceOrigins, } from './schemas' export { PublishedPageRuntimeAssetsSchema, @@ -40,6 +41,8 @@ export { export { hasPublishedRuntimeScripts, scriptTagsForRuntimeAssets, + publishedRuntimeResourceOrigins, } from './assetManifest' export { summarizeRuntimeDiagnostics, fileDiagnostics } from './diagnosticsSummary' +export { parseResourceOrigin, normalizeResourceOrigins } from './resourceOrigins' export type { RuntimeDiagnosticsSummary, FileRuntimeDiagnostics } from './diagnosticsSummary' diff --git a/src/core/site-runtime/resourceOrigins.ts b/src/core/site-runtime/resourceOrigins.ts new file mode 100644 index 000000000..828c7c384 --- /dev/null +++ b/src/core/site-runtime/resourceOrigins.ts @@ -0,0 +1,30 @@ +import { isRecord } from '@core/utils/isRecord' +import type { SiteScriptResourceOrigins } from './schemas' + +/** Origins only: never CSP keywords, wildcards, credentials or URL paths. */ +export function parseResourceOrigin(value: string): string | null { + const trimmed = value.trim() + if (!/^https?:\/\/[^/?#]+\/?$/i.test(trimmed) || /[\s;'"*\\]/.test(trimmed)) return null + try { + const url = new URL(trimmed) + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname + || url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null + return /[\s;'"*\\]/.test(url.origin) ? null : url.origin + } catch (_error) { + // Invalid user-entered URLs grant no origin. + return null + } +} + +export function normalizeResourceOrigins(raw: unknown): SiteScriptResourceOrigins | undefined { + if (!isRecord(raw)) return undefined + function origins(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return [...new Set(value.flatMap((item) => { + const origin = typeof item === 'string' ? parseResourceOrigin(item) : null + return origin ? [origin] : [] + }))].sort() + } + const result = { scripts: origins(raw.scripts), frames: origins(raw.frames), connections: origins(raw.connections) } + return result.scripts.length || result.frames.length || result.connections.length ? result : undefined +} diff --git a/src/core/site-runtime/runtimeConfig.ts b/src/core/site-runtime/runtimeConfig.ts index 6a4d8cb86..f23f4fd84 100644 --- a/src/core/site-runtime/runtimeConfig.ts +++ b/src/core/site-runtime/runtimeConfig.ts @@ -1,6 +1,7 @@ import { isSafePackageName } from '@core/site-dependencies/packageNames' import { isRecord } from '@core/utils/isRecord' import type { SiteFile } from '@core/files/schemas' +import { normalizeResourceOrigins } from './resourceOrigins' import type { LockedSiteDependency, RuntimePackageImportmap, @@ -86,6 +87,7 @@ function normalizeAssetScope(raw: unknown): SiteAssetScope { export function normalizeScriptRuntimeConfig(raw: unknown): SiteScriptRuntimeConfig { if (!isRecord(raw)) return { ...DEFAULT_SCRIPT_RUNTIME_CONFIG } + const resourceOrigins = normalizeResourceOrigins(raw.resourceOrigins) return { enabled: typeof raw.enabled === 'boolean' ? raw.enabled : DEFAULT_SCRIPT_RUNTIME_CONFIG.enabled, @@ -101,6 +103,7 @@ export function normalizeScriptRuntimeConfig(raw: unknown): SiteScriptRuntimeCon : DEFAULT_SCRIPT_RUNTIME_CONFIG.timing, scope: normalizeAssetScope(raw.scope), priority: finiteNumberOr(raw.priority, DEFAULT_SCRIPT_RUNTIME_CONFIG.priority), + ...(resourceOrigins ? { resourceOrigins } : {}), } } diff --git a/src/core/site-runtime/schemas.ts b/src/core/site-runtime/schemas.ts index bf2ab3e13..e53344ee0 100644 --- a/src/core/site-runtime/schemas.ts +++ b/src/core/site-runtime/schemas.ts @@ -99,6 +99,14 @@ export type SiteAssetScope = Static // SiteScriptRuntimeConfig // --------------------------------------------------------------------------- +const SiteScriptResourceOriginsSchema = Type.Object({ + scripts: Type.Array(Type.String()), + frames: Type.Array(Type.String()), + connections: Type.Array(Type.String()), +}) + +export type SiteScriptResourceOrigins = Static + const SiteScriptRuntimeConfigSchema = Type.Object({ enabled: Type.Boolean(), runInCanvas: Type.Boolean(), @@ -107,6 +115,7 @@ const SiteScriptRuntimeConfigSchema = Type.Object({ timing: SiteScriptTimingSchema, scope: SiteAssetScopeSchema, priority: Type.Number(), + resourceOrigins: Type.Optional(SiteScriptResourceOriginsSchema), }) export type SiteScriptRuntimeConfig = Static @@ -268,6 +277,7 @@ const PublishedRuntimeScriptAssetSchema = Type.Object({ timing: SiteScriptTimingSchema, priority: Type.Number(), integrity: Type.Optional(Type.String()), + resourceOrigins: Type.Optional(SiteScriptResourceOriginsSchema), }) export type PublishedRuntimeScriptAsset = Static