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.

Four checks, applied in order, before any fetch Each layer answers a different question about the manifest, and all four run before the host requests a single byte of remote code. What a manifest has to survive before it is trusted Signature who wrote this manifest · reject an unsigned or forged file Schema is the shape exactly as declared · strict mode rejects unknown keys Origins https only, allow-listed host · no path traversal Freshness not older than the max age · sequence never goes backwards
Order the checks cheapest-first at runtime, but understand them in this order: provenance decides whether the rest is worth reading.

Prerequisites #

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 #

Signing at publish, verifying at boot The private key never leaves the publishing pipeline, and the host carries only the public key needed to reject anything the pipeline did not produce. Publish pipeline Bucket Host Remote CDN 1. write remotes.json + .sig 2. GET both files 3. verify signature over raw text 4. schema + origin checks 5. fetch entry, integrity pinned
Verification happens over the raw response body — re-serialising the parsed object breaks the signature for entirely innocent reasons.

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 #

Why a valid signature is not enough on its own A signature proves authorship but says nothing about recency, which allows an old, genuinely-signed manifest to be replayed against users. Signature only An old manifest is still validly signed It can be replayed indefinitely Users pinned to a superseded version Including one with a known fix missing Signature + freshness generatedAt bounds how old it may be sequence never moves backwards A replayed manifest is rejected Scheduled re-signing keeps quiet periods safe
The freshness window has to be longer than your longest quiet period, or a slow week takes the remotes off the page.

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 #

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.