Validating Remote Manifests Before Loading #
Once a host resolves remotes from a manifest, that small JSON file decides what code runs in your origin — this page validates its shape, its origins and its provenance before a single byte of remote code is fetched.
The manifest is the highest-leverage artefact in a federated system and usually the least protected. It is small, it changes often, it is writable by every pipeline that publishes a remote, and nothing in the browser checks it. The steps below make it fail closed. It is the third control described in securing federated remotes.
Prerequisites #
- A host that resolves every remote through one manifest, not a mix of manifest lookups and hard-coded URLs.
- Node 20+ and Zod
^3.23.0(or Ajv, or any schema validator) available in the host bundle. - Integrity hashes published with each entry, per subresource integrity for remote entry files.
- For signature verification: an Ed25519 or ECDSA key pair, with the private key held only by the publishing pipeline.
Step 1 — Define the schema the host will accept #
Validation begins with a written contract. Anything not in the schema is rejected rather than ignored, so a manifest field nobody expected cannot quietly change behaviour.
// src/manifest-schema.ts
import { z } from 'zod';
const RemoteSpec = z.object({
entry: z.string().url(),
version: z.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?$/),
integrity: z.string().regex(/^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$/),
publishedAt: z.string().datetime(),
slotHeight: z.number().int().positive().optional(),
}).strict(); // .strict() rejects unknown keys instead of passing them through
export const ManifestSchema = z.record(z.string().min(1), RemoteSpec);
export type Manifest = z.infer<typeof ManifestSchema>;
.strict() is the important call. Without it an attacker who can append to the manifest can add fields your loader might later start reading, and the validator would say nothing.
Step 2 — Constrain the entry URLs to known origins #
A schema that accepts any valid URL accepts https://evil.example/remoteEntry.js. Origin checking is what closes that.
// src/validate-origins.ts
const ALLOWED_ORIGINS = new Set([
'https://cdn.example.com',
'https://checkout-cdn.example.com',
]);
export function assertOrigins(manifest: Manifest): Manifest {
for (const [name, spec] of Object.entries(manifest)) {
const url = new URL(spec.entry);
if (url.protocol !== 'https:') {
throw new Error(`remote "${name}" is not served over HTTPS`);
}
if (!ALLOWED_ORIGINS.has(url.origin)) {
throw new Error(`remote "${name}" resolves to unlisted origin ${url.origin}`);
}
// A path traversal in the manifest can escape the remote's own directory.
if (url.pathname.includes('..')) {
throw new Error(`remote "${name}" has a suspicious path: ${url.pathname}`);
}
}
return manifest;
}
Checking in code rather than relying on the Content Security Policy alone gives a clear, early error. CSP failures happen at fetch time, deep inside the federation runtime, and produce a message that names neither the manifest nor the remote.
Derive ALLOWED_ORIGINS and the CSP script-src list from one configuration file. Two hand-maintained lists of the same thing will diverge.
Step 3 — Verify the manifest’s signature #
Schema and origin checks confirm the manifest is well-formed and points somewhere sensible. Neither confirms who wrote it. If the manifest lives in a bucket several pipelines can write to, that is the gap worth closing.
// scripts/sign-manifest.mjs — runs in the publishing pipeline only
import { createSign } from 'node:crypto';
const body = JSON.stringify(manifest); // canonical: key order fixed on write
const sign = createSign('SHA256');
sign.update(body);
const signature = sign.sign(process.env.MANIFEST_PRIVATE_KEY, 'base64');
await put('remotes.json', body);
await put('remotes.json.sig', signature); // published together
// src/verify-signature.js — runs in the host, with the public key only
export async function verifyManifest(body, signature, publicKeyJwk) {
const key = await crypto.subtle.importKey(
'jwk', publicKeyJwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
false, ['verify'],
);
const ok = await crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
new TextEncoder().encode(body),
);
if (!ok) throw new Error('manifest signature verification failed');
return JSON.parse(body);
}
Verify over the raw response text, not over a re-serialised object. JSON.parse followed by JSON.stringify can reorder keys or normalise numbers, and the signature will stop matching for reasons that have nothing to do with tampering.
Step 4 — Compose the checks into one loader #
Order matters: cheap checks first, so a malformed manifest never reaches the expensive cryptography.
// src/get-manifest.ts
import { ManifestSchema } from './manifest-schema';
import { assertOrigins } from './validate-origins';
import { verifyManifest } from './verify-signature';
let cached: Promise<Manifest> | null = null;
export function getManifest(): Promise<Manifest> {
cached ??= (async () => {
const [res, sigRes] = await Promise.all([
fetch('/remotes.json', { cache: 'no-store' }),
fetch('/remotes.json.sig', { cache: 'no-store' }),
]);
if (!res.ok) throw new Error(`manifest unavailable: ${res.status}`);
const body = await res.text(); // raw text, for the signature
const signed = await verifyManifest(body, await sigRes.text(), PUBLIC_KEY);
const parsed = ManifestSchema.parse(signed); // shape
return assertOrigins(parsed); // origins
})();
return cached;
}
Caching the promise rather than the value means concurrent callers share one fetch and one verification, which matters because several remotes typically resolve at once during boot.
Step 5 — Decide what happens when validation fails #
A validation failure must not fall back to loading the manifest anyway. That is the only rule that makes the control meaningful.
// src/boot.ts
try {
await getManifest();
} catch (err) {
// Fail closed: the shell renders, remotes do not, and the failure is reported.
reportSecurityEvent('manifest-validation-failed', { message: String(err) });
renderShellWithoutRemotes();
}
There is a real tension here. Failing closed means a broken signature takes every remote off the page, which is a large blast radius for what might be a pipeline bug. The mitigation is not to soften the check but to make the shell genuinely useful without remotes — navigation, a clear message, and a support path. That is worth building regardless, because it is also what a total CDN outage looks like.
Step 6 — Validate in CI as well as at runtime #
Runtime validation protects users. CI validation stops the bad manifest from ever being published.
// scripts/validate-manifest.mjs — runs in every remote's publish job
import { ManifestSchema } from '../src/manifest-schema.js';
import { assertOrigins } from '../src/validate-origins.js';
const manifest = JSON.parse(await fetchText(process.env.MANIFEST_URL));
try {
assertOrigins(ManifestSchema.parse(manifest));
console.log(`manifest valid: ${Object.keys(manifest).length} remotes`);
} catch (err) {
console.error(`::error::manifest would be rejected by the host: ${err.message}`);
process.exit(1);
}
Running the same schema module in both places is the point. A separate CI-only copy drifts, and the drift is discovered by users rather than by the pipeline.
Step 7 — Guard against a stale or replayed manifest #
A signed manifest stays valid forever unless you say otherwise. That allows a replay: an attacker who can serve an old but genuinely-signed manifest can pin your users to a previous remote version — including one with a fixed vulnerability.
Two fields close it. A generatedAt timestamp bounds how old a manifest may be, and a monotonically increasing sequence prevents an older one from replacing a newer one.
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
export function assertFreshness(manifest: SignedManifest) {
const age = Date.now() - Date.parse(manifest.generatedAt);
if (age > MAX_AGE_MS) {
throw new Error(`manifest is ${Math.round(age / 3600000)}h old — refusing`);
}
const seen = Number(localStorage.getItem('manifest-seq') ?? 0);
if (manifest.sequence < seen) {
throw new Error(`manifest sequence ${manifest.sequence} is older than ${seen}`);
}
localStorage.setItem('manifest-seq', String(manifest.sequence));
}
Choose the maximum age carefully. It has to exceed your longest normal gap between publishes, or a quiet weekend will take the site down. Twenty-four hours suits a system that deploys most days; a weekly cadence needs a correspondingly longer window and a job that re-signs the manifest on a schedule even when nothing changed.
Verification #
- Schema rejects extra keys: add an unexpected field to a staging manifest and confirm the host refuses it.
- Origins are enforced: point one entry at an unlisted origin and confirm the error names the remote and the origin.
- Signature is checked: change one byte of the manifest without re-signing. Verification must fail.
- Raw text is verified: confirm that re-serialising the parsed object produces a different string than the response body — proof you are verifying the right bytes.
- Fail-closed is real: break the signature in staging and confirm the shell renders with navigation intact and a reported security event.
- CI blocks first: publish an invalid manifest from a test pipeline and confirm the job fails before anything is uploaded.
Troubleshooting #
Symptom: signature verification fails intermittently.
Diagnosis: the manifest is being re-serialised somewhere between signing and verifying — most often by a CDN that pretty-prints or minifies JSON. Fix: serve the manifest with a content type the CDN will not transform, and verify over the exact response text.
Symptom: validation fails immediately after adding a remote.
Diagnosis: the new remote’s pipeline writes a field the schema does not declare, and .strict() rejects it. Fix: this is the schema working. Add the field deliberately, with a type, in the shared module both sides import.
Symptom: every user fails validation after a key rotation.
Diagnosis: the host bundle still embeds the old public key. Fix: support two keys during rotation — accept either, publish signed with the new one, then remove the old key in a later release.
Symptom: freshness check fires during a quiet period.
Diagnosis: MAX_AGE_MS is shorter than the real gap between publishes. Fix: lengthen the window and add a scheduled job that re-signs the unchanged manifest, so freshness reflects liveness rather than release frequency.