Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
Expand All @@ -356,6 +357,8 @@ type SiteRuntimeConfig = {

`scripts` and `styles` share the `SiteAssetScope` shape and the `assetScopeAppliesToPage` helper, so a script and a stylesheet target pages identically. Scripts additionally carry `placement`/`timing`/`runInCanvas` (a `<link>` 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
Expand Down
2 changes: 2 additions & 0 deletions server/publish/runtime/bundleScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
})
}

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/code-editor/scriptSettingsPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CodeEditorPanel />)
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}`,
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/publisher/cspPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/server/siteRuntimeBuild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,6 +61,36 @@ function runtimeSite(overrides: Partial<SiteDocument> = {}): 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(),
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/site-runtime/resourceOrigins.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
18 changes: 18 additions & 0 deletions src/admin/pages/site/code-editor/ResourceOriginsControl.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 42 additions & 0 deletions src/admin/pages/site/code-editor/ResourceOriginsControl.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)
const [error, setError] = useState(false)
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>{label}</label>
<Input
id={id}
aria-label={`Allowed ${label.toLowerCase()} origins`}
aria-invalid={error}
aria-describedby={error ? `${id}-error` : undefined}
fieldSize="xs"
placeholder="https://provider.example"
value={draft ?? origins.join(', ')}
onChange={(event) => { 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 && <span id={`${id}-error`} className={styles.validationError} role="alert">
Use HTTP(S) origins without paths or credentials. Separate them with commas.
</span>}
</div>
)
}
18 changes: 18 additions & 0 deletions src/admin/pages/site/code-editor/ScriptSettingsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -138,6 +139,23 @@ export function ScriptSettingsPane({ file }: ScriptSettingsPaneProps) {
/>
</div>

<div className={styles.field} aria-label="External resources">
<span className={styles.label}>External resources</span>
{(['scripts', 'frames', 'connections'] as const).map((kind) => (
<ResourceOriginsControl
key={`${file.id}:${kind}`}
label={kind.charAt(0).toUpperCase() + kind.slice(1)}
origins={config.resourceOrigins?.[kind] ?? []}
onChange={(origins) => patch({ resourceOrigins: {
scripts: config.resourceOrigins?.scripts ?? [],
frames: config.resourceOrigins?.frames ?? [],
connections: config.resourceOrigins?.connections ?? [],
[kind]: origins,
} })}
/>
))}
</div>

{(runtimePackages.length > 0 || diagnostics.length > 0) && (
<div className={styles.imports} aria-label="Script imports">
{runtimePackages.length > 0 && (
Expand Down
14 changes: 11 additions & 3 deletions src/core/publisher/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -462,8 +462,16 @@ function buildContentSecurityPolicy(
anyScriptTag: boolean,
importmap: PublishedRuntimePackageImportmap | undefined,
moduleCspSources: ReadonlyMap<string, ReadonlySet<string>>,
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 <origins> on pages that
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/core/site-runtime-schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type {
SiteScriptPlacement,
SiteScriptRuntimeConfig,
SiteScriptTiming,
SiteScriptResourceOrigins,
SiteStyleRuntimeConfig,
} from '../site-runtime/schemas'

Expand Down
10 changes: 10 additions & 0 deletions src/core/site-runtime/assetManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/core/site-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type {
SiteScriptFormat,
SiteScriptPlacement,
SiteScriptTiming,
SiteScriptResourceOrigins,
} from './schemas'
export {
PublishedPageRuntimeAssetsSchema,
Expand Down Expand Up @@ -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'
30 changes: 30 additions & 0 deletions src/core/site-runtime/resourceOrigins.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions src/core/site-runtime/runtimeConfig.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {}),
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/core/site-runtime/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export type SiteAssetScope = Static<typeof SiteAssetScopeSchema>
// SiteScriptRuntimeConfig
// ---------------------------------------------------------------------------

const SiteScriptResourceOriginsSchema = Type.Object({
scripts: Type.Array(Type.String()),
frames: Type.Array(Type.String()),
connections: Type.Array(Type.String()),
})

export type SiteScriptResourceOrigins = Static<typeof SiteScriptResourceOriginsSchema>

const SiteScriptRuntimeConfigSchema = Type.Object({
enabled: Type.Boolean(),
runInCanvas: Type.Boolean(),
Expand All @@ -107,6 +115,7 @@ const SiteScriptRuntimeConfigSchema = Type.Object({
timing: SiteScriptTimingSchema,
scope: SiteAssetScopeSchema,
priority: Type.Number(),
resourceOrigins: Type.Optional(SiteScriptResourceOriginsSchema),
})

export type SiteScriptRuntimeConfig = Static<typeof SiteScriptRuntimeConfigSchema>
Expand Down Expand Up @@ -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<typeof PublishedRuntimeScriptAssetSchema>
Expand Down