Skip to content

Commit 586a45d

Browse files
committed
runtime: enforce security host policy for remote fallback fetches
1 parent ad4b60e commit 586a45d

5 files changed

Lines changed: 180 additions & 4 deletions

File tree

packages/runtime/src/embed.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,23 @@ export async function renderPlanInBrowser(
2222
options.autoPinModuleLoader ??
2323
options.runtimeOptions?.moduleLoader ??
2424
new JspmModuleLoader();
25+
const security = options.security ?? new DefaultSecurityChecker();
26+
security.initialize(options.securityInitialization);
27+
const policy = security.getPolicy();
2528
const runtime =
2629
options.runtime ??
2730
new DefaultRuntimeManager({
2831
moduleLoader,
2932
...(options.runtimeOptions ?? {}),
33+
allowArbitraryNetwork: policy.allowArbitraryNetwork,
34+
allowedNetworkHosts: policy.allowedNetworkHosts,
3035
});
31-
const security = options.security ?? new DefaultSecurityChecker();
3236

3337
const shouldInitializeRuntime =
3438
options.autoInitializeRuntime !== false || options.runtime === undefined;
3539
const shouldTerminateRuntime =
3640
options.autoTerminateRuntime !== false && options.runtime === undefined;
3741

38-
security.initialize(options.securityInitialization);
39-
4042
if (shouldInitializeRuntime) {
4143
await runtime.initialize();
4244
}

packages/runtime/src/manager.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export class DefaultRuntimeManager implements RuntimeManager {
128128
private readonly remoteFetchRetries: number;
129129
private readonly remoteFetchBackoffMs: number;
130130
private readonly remoteFallbackCdnBases: string[];
131+
private readonly allowArbitraryNetwork: boolean;
132+
private readonly allowedNetworkHosts: Set<string>;
131133
private readonly browserModuleUrlCache = new Map<string, string>();
132134
private readonly browserModuleInflight = new Map<string, Promise<string>>();
133135
private readonly browserBlobUrls = new Set<string>();
@@ -186,6 +188,13 @@ export class DefaultRuntimeManager implements RuntimeManager {
186188
this.remoteFallbackCdnBases = normalizeFallbackCdnBases(
187189
options.remoteFallbackCdnBases,
188190
);
191+
this.allowArbitraryNetwork = options.allowArbitraryNetwork ?? true;
192+
this.allowedNetworkHosts = new Set(
193+
(options.allowedNetworkHosts ?? [])
194+
.filter((entry): entry is string => typeof entry === "string")
195+
.map((entry) => entry.trim().toLowerCase())
196+
.filter((entry) => entry.length > 0),
197+
);
189198
}
190199

191200
async initialize(): Promise<void> {
@@ -635,6 +644,7 @@ export class DefaultRuntimeManager implements RuntimeManager {
635644
runtimeDiagnostics,
636645
requireManifest,
637646
),
647+
isRemoteUrlAllowed: (url) => this.isRemoteUrlAllowed(url),
638648
});
639649
}
640650

@@ -695,6 +705,29 @@ export class DefaultRuntimeManager implements RuntimeManager {
695705
return normalizeRuntimeSourceOutput(output);
696706
}
697707

708+
private isRemoteUrlAllowed(url: string): boolean {
709+
if (this.allowArbitraryNetwork) {
710+
return true;
711+
}
712+
713+
if (this.allowedNetworkHosts.size === 0) {
714+
return false;
715+
}
716+
717+
let parsedUrl: URL;
718+
try {
719+
parsedUrl = new URL(url);
720+
} catch {
721+
return false;
722+
}
723+
724+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
725+
return false;
726+
}
727+
728+
return this.allowedNetworkHosts.has(parsedUrl.host.toLowerCase());
729+
}
730+
698731
private async resolveNode(
699732
node: RuntimeNode,
700733
moduleManifest: RuntimeModuleManifest | undefined,

packages/runtime/src/runtime-manager.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export interface RuntimeManagerOptions {
8787
remoteFetchRetries?: number;
8888
remoteFetchBackoffMs?: number;
8989
remoteFallbackCdnBases?: string[];
90+
allowArbitraryNetwork?: boolean;
91+
allowedNetworkHosts?: string[];
9092
}
9193

9294
export interface RuntimeSourceTranspileInput {

packages/runtime/src/runtime-source-module-loader.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface RuntimeSourceModuleLoaderOptions {
3636
diagnostics: RuntimeDiagnostic[],
3737
requireManifest?: boolean,
3838
) => string;
39+
isRemoteUrlAllowed?: (url: string) => boolean;
3940
}
4041

4142
export class RuntimeSourceModuleLoader {
@@ -59,6 +60,7 @@ export class RuntimeSourceModuleLoader {
5960
diagnostics: RuntimeDiagnostic[],
6061
requireManifest?: boolean,
6162
) => string;
63+
private readonly isRemoteUrlAllowedFn: (url: string) => boolean;
6264

6365
constructor(options: RuntimeSourceModuleLoaderOptions) {
6466
this.moduleManifest = options.moduleManifest;
@@ -74,6 +76,7 @@ export class RuntimeSourceModuleLoader {
7476
this.createInlineModuleUrlFn = options.createInlineModuleUrl;
7577
this.resolveRuntimeSourceSpecifierFn =
7678
options.resolveRuntimeSourceSpecifier;
79+
this.isRemoteUrlAllowedFn = options.isRemoteUrlAllowed ?? (() => true);
7780
}
7881

7982
async importSourceModuleFromCode(code: string): Promise<unknown> {
@@ -241,11 +244,18 @@ export class RuntimeSourceModuleLoader {
241244
throw new Error(`Failed to load module: ${url}`);
242245
}
243246

247+
const filteredAttempts = this.filterDisallowedAttempts(attempts);
248+
if (filteredAttempts.length === 0) {
249+
throw new Error(
250+
`Remote module URL is blocked by runtime network policy: ${url}`,
251+
);
252+
}
253+
244254
const hedgeDelayMs = Math.max(
245255
50,
246256
Math.min(300, this.remoteFetchBackoffMs || 100),
247257
);
248-
const fetchTasks = attempts.map((attempt, index) =>
258+
const fetchTasks = filteredAttempts.map((attempt, index) =>
249259
this.fetchRemoteModuleAttemptWithRetries(
250260
attempt,
251261
url,
@@ -337,6 +347,24 @@ export class RuntimeSourceModuleLoader {
337347
}
338348
}
339349

350+
private filterDisallowedAttempts(attempts: string[]): string[] {
351+
const allowed: string[] = [];
352+
for (const attempt of attempts) {
353+
if (this.isRemoteUrlAllowedFn(attempt)) {
354+
allowed.push(attempt);
355+
continue;
356+
}
357+
358+
this.diagnostics.push({
359+
level: "warning",
360+
code: "RUNTIME_SOURCE_IMPORT_BLOCKED",
361+
message: `Blocked remote module URL by runtime network policy: ${attempt}`,
362+
});
363+
}
364+
365+
return allowed;
366+
}
367+
340368
private errorToMessage(error: unknown): string {
341369
if (error instanceof Error) {
342370
return error.message;

tests/runtime.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,39 @@ test("renderPlanInBrowser rejects plan when security policy fails", async () =>
648648
);
649649
});
650650

651+
test("renderPlanInBrowser binds default runtime network policy to security policy", async () => {
652+
const plan: RuntimePlan = {
653+
specVersion: DEFAULT_RUNTIME_PLAN_SPEC_VERSION,
654+
id: "embed_runtime_security_network_policy",
655+
version: 1,
656+
root: createElementNode("section", undefined, [createTextNode("ok")]),
657+
capabilities: {
658+
domWrite: true,
659+
},
660+
};
661+
662+
const result = await renderPlanInBrowser(plan, {
663+
runtimeOptions: {
664+
browserSourceSandboxMode: "none",
665+
remoteFallbackCdnBases: ["https://esm.sh"],
666+
},
667+
securityInitialization: {
668+
profile: "strict",
669+
},
670+
});
671+
672+
const runtime = result.runtime as unknown as {
673+
allowArbitraryNetwork?: boolean;
674+
allowedNetworkHosts?: Set<string>;
675+
};
676+
677+
assert.equal(runtime.allowArbitraryNetwork, false);
678+
assert.deepEqual(
679+
[...(runtime.allowedNetworkHosts ?? new Set<string>())].sort(),
680+
["cdn.jspm.io", "ga.jspm.io"],
681+
);
682+
});
683+
651684
test("renderPlanInBrowser serializes concurrent renders for the same target", async () => {
652685
const planA: RuntimePlan = {
653686
specVersion: DEFAULT_RUNTIME_PLAN_SPEC_VERSION,
@@ -719,6 +752,10 @@ test("renderPlanInBrowser serializes concurrent renders for the same target", as
719752

720753
const security = {
721754
initialize: () => {},
755+
getPolicy: () => ({
756+
allowArbitraryNetwork: true,
757+
allowedNetworkHosts: [],
758+
}),
722759
checkPlan: () => ({
723760
safe: true,
724761
issues: [],
@@ -1651,6 +1688,80 @@ test("runtime source loader hedges fallback CDN requests", async () => {
16511688
}
16521689
});
16531690

1691+
test("runtime source loader skips fallback URLs blocked by network policy", async () => {
1692+
const runtime = new DefaultRuntimeManager({
1693+
remoteFallbackCdnBases: ["https://esm.sh"],
1694+
remoteFetchRetries: 0,
1695+
remoteFetchBackoffMs: 10,
1696+
remoteFetchTimeoutMs: 1200,
1697+
allowArbitraryNetwork: false,
1698+
allowedNetworkHosts: ["ga.jspm.io", "cdn.jspm.io"],
1699+
});
1700+
1701+
const diagnostics: Array<{ code?: string; message?: string }> = [];
1702+
const loader = (
1703+
runtime as unknown as {
1704+
createSourceModuleLoader: (
1705+
moduleManifest: RuntimeModuleManifest | undefined,
1706+
diagnostics: Array<{ code?: string; message?: string }>,
1707+
) => {
1708+
fetchRemoteModuleCodeWithFallback(
1709+
url: string,
1710+
): Promise<{ requestUrl: string }>;
1711+
};
1712+
}
1713+
).createSourceModuleLoader(undefined, diagnostics);
1714+
1715+
const requestedUrls: string[] = [];
1716+
const originalFetch = globalThis.fetch;
1717+
globalThis.fetch = (async (input: RequestInfo | URL) => {
1718+
const requestUrl = String(input);
1719+
requestedUrls.push(requestUrl);
1720+
1721+
if (requestUrl.startsWith("https://ga.jspm.io/")) {
1722+
return new Response("slow-failure", { status: 503 });
1723+
}
1724+
1725+
if (requestUrl.startsWith("https://esm.sh/")) {
1726+
return new Response("export default 1;", {
1727+
status: 200,
1728+
headers: {
1729+
"content-type": "text/javascript; charset=utf-8",
1730+
},
1731+
});
1732+
}
1733+
1734+
return new Response("not-found", { status: 404 });
1735+
}) as typeof fetch;
1736+
1737+
try {
1738+
await assert.rejects(
1739+
loader.fetchRemoteModuleCodeWithFallback(
1740+
"https://ga.jspm.io/npm:lit@3.3.0/index.js",
1741+
),
1742+
);
1743+
1744+
assert.ok(
1745+
requestedUrls.some((url) => url.startsWith("https://ga.jspm.io/")),
1746+
"expected primary JSPM URL to be requested",
1747+
);
1748+
assert.equal(
1749+
requestedUrls.some((url) => url.startsWith("https://esm.sh/")),
1750+
false,
1751+
"did not expect blocked fallback host to be requested",
1752+
);
1753+
assert.ok(
1754+
diagnostics.some(
1755+
(item) =>
1756+
item.code === "RUNTIME_SOURCE_IMPORT_BLOCKED" &&
1757+
item.message?.includes("https://esm.sh/"),
1758+
),
1759+
);
1760+
} finally {
1761+
globalThis.fetch = originalFetch;
1762+
}
1763+
});
1764+
16541765
test("runtime source loader supports disabling fallback cdn attempts", async () => {
16551766
const runtime = new DefaultRuntimeManager({
16561767
remoteFallbackCdnBases: [],

0 commit comments

Comments
 (0)