Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/current/PACKAGE_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ unaffected: their `tagName` export remains the registration tag.
`cli/start --mode=preview` (#859); `PageRenderingMode` narrowed to
`'static' | 'dynamic'` (#609).

### v0.44 experimental additions (unfrozen)

| Export (element root) | Class | Purpose | Removal/move condition |
| ----------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `isDangerousKey`, `injectPropsSafe`, `DANGEROUS_KEYS` | experimental (#1214) | The one canonical prototype-pollution guard shared by page projection (SPA bootstrap, `projectPageProps`) and adapter codegen, which serializes `DANGEROUS_KEYS` into generated server runtimes at build time | May move to a dedicated security subpath at the B1.2 surface freeze |

## Removed from current graph

The following alpha implementation packages are absorbed and are not supported
Expand Down
5 changes: 4 additions & 1 deletion docs/release/public-interface-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"declarations": {
".": {
"publicShapeSha256": "b28b4b74f79c2ac04bc143dffe92671a975b71026125667b72872164c26f31ab",
"publicShapeSha256": "3334cfeb124b5ad4d7f22fc6647ed8170fd087fc65412a46852a1d26b670b1bb",
"publicSymbols": [
"ACTION_FETCH_HEADER=value:\"x-openelement-action\"",
"Action=type:{call:(ctx:{env:Env;formData:FormData;params:Record<string, string>;platform:union(Platform|undefined);request:Request;responseHeaders:Headers;route:Route})=>union(Promise<T>|T)}",
Expand All @@ -23,6 +23,7 @@
"CompatibilityTier=type:union(\"client-only\"|\"experimental-dom\"|\"rejected\"|\"ssr-capable\")",
"ComponentLayer=type:union(\"dsd-interactive\"|\"dsd-static\"|\"light-dom\"|\"pure-island\")",
"Context=type:{readonly defaultValue:T;readonly key:symbol}",
"DANGEROUS_KEYS=value:ReadonlySet<string>",
"DATA_SSR_PROPS=value:\"data-ssr-props\"",
"ERROR_PREFIX=value:\"[openElement]\"",
"ErrorBoundary=type:{_errors:{capture:{call:(error:unknown,source:unknown)=>void};catchError:{call:(error:unknown,source:unknown)=>void};dispose:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record<string, unknown>}});hasError:union(false|true);maxRetries:number;reset:{call:()=>void};retry:{call:(recover:union(undefined|{call:()=>void}))=>union(false|true)};retryCount:number;source:unknown};_getLocale:{call:(fallback:string)=>string};_internals:union(ElementInternals|undefined);_lifecycleSignal:{call:()=>AbortSignal};_requestAnimationFrame:{call:(callback:FrameRequestCallback)=>number};_setTimeout:{call:(handler:union(Function|string),timeout:union(number|undefined))=>number};adoptedCallback:{call:()=>void};attributeChangedCallback:{call:(name:string,_oldValue:union(null|string),newValue:union(null|string))=>void};catchError:{call:(error:Error,source:unknown)=>void};clientActivate:{call:()=>void};connectedCallback:{call:()=>void};disconnectedCallback:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record<string, unknown>}});formAssociatedCallback:{call:(_form:union(HTMLFormElement|null))=>void};formResetCallback:{call:()=>void};formStateRestoreCallback:{call:(state:union(File|FormData|null|string),mode:string)=>void};hasError:union(false|true);locale?:union(string|undefined);maxRetries:number;onCsrRendered:{call:()=>void};onDsdHydrated:{call:()=>void};params:Record<string, string>;reset:{call:()=>void};retry:{call:()=>void};retryCount:number}|value:{_resetGlobalStyles:{call:()=>void};client?:union(undefined|{hydrate?:union(\"idle\"|\"load\"|\"only\"|\"visible\"|undefined)});construct:()=>{_errors:{capture:{call:(error:unknown,source:unknown)=>void};catchError:{call:(error:unknown,source:unknown)=>void};dispose:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record<string, unknown>}});hasError:union(false|true);maxRetries:number;reset:{call:()=>void};retry:{call:(recover:union(undefined|{call:()=>void}))=>union(false|true)};retryCount:number;source:unknown};_getLocale:{call:(fallback:string)=>string};_internals:union(ElementInternals|undefined);_lifecycleSignal:{call:()=>AbortSignal};_requestAnimationFrame:{call:(callback:FrameRequestCallback)=>number};_setTimeout:{call:(handler:union(Function|string),timeout:union(number|undefined))=>number};adoptedCallback:{call:()=>void};attributeChangedCallback:{call:(name:string,_oldValue:union(null|string),newValue:union(null|string))=>void};catchError:{call:(error:Error,source:unknown)=>void};clientActivate:{call:()=>void};connectedCallback:{call:()=>void};disconnectedCallback:{call:()=>void};error:union(null|{readonly code:string;readonly phase:union(\"build\"|\"csr\"|\"navigation\"|\"render\"|\"ssr\"|\"unknown\"|\"validation\");readonly recoverable:union(false|true);readonly severity:union(\"error\"|\"warning\");readonly statusCode?:union(number|undefined);toJSON:{call:()=>Record<string, unknown>}});formAssociatedCallback:{call:(_form:union(HTMLFormElement|null))=>void};formResetCallback:{call:()=>void};formStateRestoreCallback:{call:(state:union(File|FormData|null|string),mode:string)=>void};hasError:union(false|true);locale?:union(string|undefined);maxRetries:number;onCsrRendered:{call:()=>void};onDsdHydrated:{call:()=>void};params:Record<string, string>;reset:{call:()=>void};retry:{call:()=>void};retryCount:number};delegatesFocus?:union(false|true|undefined);formAssociated?:union(false|true|undefined);getGlobalStyles:{call:()=>array({readonly cssRules:array({cssText:string});replaceSync:{call:(text:string)=>void}})};head?:union(undefined|{description?:union(string|undefined);ogImage?:union(string|undefined);title?:union(string|undefined)});isErrorBoundary:union(false|true);registerGlobalStyles:{call:(sheets:unknown)=>void};renderMode?:union(\"light\"|\"shadow\"|undefined);styles?:union(array({readonly cssRules:array({cssText:string});replaceSync:{call:(text:string)=>void}})|undefined|{readonly cssRules:array({cssText:string});replaceSync:{call:(text:string)=>void}})}",
Expand Down Expand Up @@ -76,6 +77,8 @@
"escapeAttr=value:{call:(value:string)=>string}",
"escapeHtml=value:{call:(str:string)=>string}",
"formatError=value:{call:(e:unknown)=>string}",
"injectPropsSafe=value:{call:(target:Record<string, unknown>,props:Record<string, unknown>,tagName:string,log:{debug:{call:(message:string)=>void};warn:{call:(message:string)=>void}})=>void}",
"isDangerousKey=value:{call:(key:string)=>union(false|true)}",
"isSafeAttributeName=value:{call:(name:string)=>union(false|true)}",
"isValidTagName=value:{call:(tagName:string)=>union(false|true)}",
"provideContext=value:{call:<T>(host:HTMLElement,context:{readonly defaultValue:T;readonly key:symbol},value:T)=>{call:()=>void}}",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Generated server runtime page-projection guard tests (#1214).
*
* The generated entry module carries its own copy of the default page
* projector (__defaultPageProps) plus the descriptor projector seams
* (__pageProps / __pageErrorProps). All three must filter the canonical
* dangerous keys (packages/element/src/internal/core/security.ts) so hostile
* route params, loader data, or author projector records can never pollute
* the props record that flows into renderDsd.
*/
import { assertEquals } from '@std/assert';
import { renderRuntimeHelpers } from '../src/internal/ssg/entry-render-runtime.ts';

const HOSTILE_JSON =
'{"__proto__": {"polluted": true}, "constructor": {"evil": true}, "prototype": {"evil": true}, "title": "legit"}';

interface ProjectionHarness {
defaultProps(
context: { params?: Record<string, string>; data?: unknown },
): Record<string, unknown>;
pageProps(routeModule: unknown, context: Record<string, unknown>): Record<string, unknown>;
pageErrorProps(
routeModule: unknown,
error: unknown,
context: Record<string, unknown>,
): Record<string, unknown>;
}

async function loadHarness(): Promise<ProjectionHarness> {
const helpers = renderRuntimeHelpers({ default: false, layouts: {} }, []);
const harness = `
const customElements = { get() { return undefined; } };
const escapeHtml = (value) => String(value);
const __locales = ["en"];
const __getDefaultLocale = () => "en";
const __navSections = [];
const __headerNav = [];
function renderDsd() { return { html: "" }; }
${helpers}
export function defaultProps(context) { return __defaultPageProps(context); }
export function pageProps(routeModule, context) { return __pageProps(routeModule, context); }
export function pageErrorProps(routeModule, error, context) { return __pageErrorProps(routeModule, error, context); }
`;
const mod = await import('data:text/javascript;charset=utf-8,' + encodeURIComponent(harness));
return mod as ProjectionHarness;
}

function assertFiltered(record: Record<string, unknown>, expected: Record<string, unknown>): void {
assertEquals(Object.getPrototypeOf(record), Object.prototype);
assertEquals(Object.hasOwn(record, '__proto__'), false);
assertEquals(Object.hasOwn(record, 'constructor'), false);
assertEquals(Object.hasOwn(record, 'prototype'), false);
assertEquals(record, expected);
}

Deno.test('__defaultPageProps filters dangerous keys from params and loader data (#1214)', async () => {
const harness = await loadHarness();
const params = JSON.parse(
'{"__proto__": "x", "constructor": "y", "prototype": "z", "id": "42"}',
) as Record<string, string>;
const data = JSON.parse(HOSTILE_JSON) as Record<string, unknown>;
const projected = harness.defaultProps({ params, data });
assertFiltered(projected, { id: '42', title: 'legit' });
assertEquals(({} as { polluted?: unknown }).polluted, undefined);
});

Deno.test('__defaultPageProps keeps full parity for legitimate keys (#1214)', async () => {
const harness = await loadHarness();
assertEquals(
harness.defaultProps({ params: { id: '42' }, data: { title: 'Hello', n: 1 } }),
{ id: '42', title: 'Hello', n: 1 },
);
assertEquals(harness.defaultProps({ params: { id: '7' }, data: ['a'] }), { id: '7' });
assertEquals(harness.defaultProps({}), {});
});

Deno.test('__pageProps filters dangerous keys returned by the descriptor props projector (#1214)', async () => {
const harness = await loadHarness();
const routeModule = {
default: {
openElementPage: {
props: () => JSON.parse(HOSTILE_JSON) as Record<string, unknown>,
},
},
};
assertFiltered(harness.pageProps(routeModule, { data: {}, params: {} }), { title: 'legit' });
});

Deno.test('__pageErrorProps filters dangerous keys returned by the descriptor error projector (#1214)', async () => {
const harness = await loadHarness();
const routeModule = {
default: {
openElementPage: {
error: () => JSON.parse(HOSTILE_JSON) as Record<string, unknown>,
},
},
};
assertFiltered(harness.pageErrorProps(routeModule, new Error('boom'), {}), { title: 'legit' });
});
39 changes: 35 additions & 4 deletions packages/adapter-vite/src/internal/ssg/entry-render-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@

import type { AppShellPlan } from '../protocol/ssg.ts';
import { quoteGeneratedJavaScriptValue } from './codegen-literals.ts';
// The canonical dangerous-key rule lives in @openelement/element
// (constitution 4.2 — one rule, no second copy). Generated server entries
// cannot import Element at all in some consumer setups, so the canonical
// list is serialized into the generated code here, exactly like the admitted
// tag list below; changing the canonical guard automatically re-derives the
// copy.
import { DANGEROUS_KEYS } from '@openelement/element';

/**
* Render all runtime helper function definitions as a single code block.
Expand Down Expand Up @@ -89,13 +96,33 @@ export function renderRuntimeHelpers(

// Page props projection: the page descriptor's props projector is the only
// channel that maps request-scoped data onto the compiled page properties.
// Every projection seam fails closed for the canonical dangerous keys
// (#1214): the list is serialized from Element's security.ts so generated
// code enforces the one canonical rule without importing Element internals.
lines.push(
`const __DANGEROUS_KEYS = new Set(${quoteGeneratedJavaScriptValue([...DANGEROUS_KEYS])});`,
);
lines.push('function __filterPageProps(record) {');
lines.push(' const clean = {}');
lines.push(' for (const key of Object.keys(record)) {');
lines.push(' if (__DANGEROUS_KEYS.has(key)) continue');
lines.push(' clean[key] = record[key]');
lines.push(' }');
lines.push(' return clean');
lines.push('}');
lines.push('function __defaultPageProps(context) {');
lines.push(' const props = {}');
lines.push(' const params = context.params || {}');
lines.push(' for (const key of Object.keys(params)) props[key] = params[key]');
lines.push(' for (const key of Object.keys(params)) {');
lines.push(' if (__DANGEROUS_KEYS.has(key)) continue');
lines.push(' props[key] = params[key]');
lines.push(' }');
lines.push(' const data = context.data');
lines.push(' if (data && typeof data === "object" && !Array.isArray(data)) {');
lines.push(' for (const key of Object.keys(data)) props[key] = data[key]');
lines.push(' for (const key of Object.keys(data)) {');
lines.push(' if (__DANGEROUS_KEYS.has(key)) continue');
lines.push(' props[key] = data[key]');
lines.push(' }');
lines.push(' }');
lines.push(' return props');
lines.push('}');
Expand All @@ -105,7 +132,9 @@ export function renderRuntimeHelpers(
);
lines.push(' if (page && typeof page.props === "function") {');
lines.push(' const projected = page.props(context)');
lines.push(' return projected && typeof projected === "object" ? projected : {}');
lines.push(
' return projected && typeof projected === "object" ? __filterPageProps(projected) : {}',
);
lines.push(' }');
lines.push(' return __defaultPageProps(context)');
lines.push('}');
Expand All @@ -116,7 +145,9 @@ export function renderRuntimeHelpers(
lines.push(
' const projected = page && typeof page.error === "function" ? page.error(error, context) : {}',
);
lines.push(' return projected && typeof projected === "object" ? projected : {}');
lines.push(
' return projected && typeof projected === "object" ? __filterPageProps(projected) : {}',
);
lines.push('}');
lines.push('');

Expand Down
Loading
Loading