Suppose an attacker compromises the manifest server. They have full control: they can rewrite every response, change every URL, serve whatever JSON they want.
What can they do? They can tell browsers to fetch different content-hashed chunks from the CDN. Chunks that already exist. Chunks that were produced by legitimate builds.
What can’t they do? They can’t inject new JavaScript. They can’t create a chunk that doesn’t already exist on the CDN. They can’t modify a chunk’s contents without invalidating its content hash. And they can’t forge the ed25519 signature that covers those hashes.
This is the security property that the entire Adaptive Bundle Service is designed around. The manifest server is not trusted with code. It is trusted with routing. And routing to existing, signed, content-hashed artifacts is a smaller attack surface than serving arbitrary content.
The industry default
Most web applications rely on two mechanisms for delivery integrity: HTTPS between CDN and browser, and Subresource Integrity (SRI) attributes in HTML.
HTTPS prevents a network attacker from modifying content in transit. It does nothing if the server itself is compromised. SRI hashes are embedded in <script> tags, so if an attacker controls the HTML, they control the hashes too. Both mechanisms assume the origin is trustworthy. Neither protects against a compromised origin.
Cloudpack separates the concerns. The CDN serves content-hashed chunks: the filename is the integrity check. The manifest server tells browsers which chunks to load. The build system signs the manifest with a key the server never sees. Three systems, three trust boundaries, three different things an attacker needs to compromise.
What gets signed
The manifest signature covers exactly two things: the build ID and the sorted list of chunk hashes. Nothing else.
Here’s the canonical format from signing.rs:
pub fn manifest_signature_bytes(manifest: &ChunkManifest) -> Vec<u8> {
let mut buf = String::new();
// Write build_id line.
buf.push_str(&manifest.build_id);
buf.push('\n');
// Collect and sort chunks by id for deterministic output.
let mut chunks: Vec<_> = manifest.chunks.iter().collect();
chunks.sort_by(|a, b| a.id.cmp(&b.id));
for chunk in chunks {
buf.push_str(&chunk.id);
buf.push(' ');
buf.push_str(chunk.hash.as_str());
buf.push('\n');
}
buf.into_bytes()
}
The byte representation is deliberate: build_id on the first line, then one line per chunk as {chunk_id} {hash_hex}, sorted by chunk ID. Deterministic. No ambiguity. No room for canonicalization bugs.
The signer uses ed25519 via ed25519_dalek. The signing key lives in the build system, stored as PKCS8 PEM. The manifest server never holds the private key. It receives a pre-signed manifest and serves it.
pub fn sign_manifest(&self, manifest: &ChunkManifest) -> Signature {
use ed25519_dalek::Signer as _;
let bytes = manifest_signature_bytes(manifest);
self.signing_key.sign(&bytes)
}
The signature ships as an X-Wundler-Signature header on GET /manifest/full.json, base64-encoded.
Why PGO fields are excluded
The PGO optimization pipeline updates three fields on each chunk: co_request_score (the probability that this chunk is needed given an initial load), median_load_order (when in a session it’s typically fetched), and suggested_merge (a recommendation to merge this chunk with another).
These fields change between builds. The PGO system writes them back into the manifest after analyzing real browser sessions. If the signature covered them, every PGO update would invalidate the signature and require re-signing with the build key. That would couple the optimization pipeline to the build system’s signing infrastructure, defeating the purpose of a decoupled feedback loop.
So the signature deliberately excludes them. The PGO pipeline can update advisory fields in place without touching the build system. The signature remains valid because it only covers the chunk hashes, and those haven’t changed. An attacker who modifies co_request_score can change prefetch priority. They cannot change what code gets executed.
The threat model
The key insight: to inject code, an attacker needs both the ed25519 signing key (held by the build system, never by the server) and write access to the CDN (to upload a chunk whose content hash matches the signed manifest). Compromising the manifest server alone is insufficient.
Replay attacks are the remaining surface. An attacker can serve an older, legitimately signed manifest. The browser loads a stale version of the application. This is a downgrade attack, not a code injection attack. It’s detectable via build-ID monitoring, and the blast radius is bounded: the old code was legitimate code that shipped through the same build pipeline.
Loopback-only hot-swap
The manifest server exposes two operator endpoints for hot-swapping the active manifest: POST /reload (load a new manifest from disk) and POST /select (roll back to an archived build).
Both endpoints enforce a loopback check. If the request arrives over a real TCP connection, the source IP must be 127.0.0.1 or ::1. From server.rs:
if let Some(addr) = addr {
if !addr.ip().is_loopback() {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "reload is only permitted from loopback addresses"
})),
)
.into_response();
}
}
This is a defense-in-depth measure. Even if an attacker owns the manifest server process, they can’t trigger a reload remotely. The reload endpoint reads a manifest from the local filesystem, re-signs it if a signer is configured, installs it in the archive, and atomically swaps the in-memory state. That operation must originate from the same machine.
When a signer is configured, /reload re-signs the new manifest immediately after swapping:
if let Some(signer) = &state.signer {
let active_manifest = state.app.snapshot().await;
let sig = signer.sign_manifest(&active_manifest);
state.app.set_signature(Some(sig)).await;
}
No window where a manifest is served without a valid signature.
The escape hatch
The service worker is the enforcement point. On every navigation, it fetches the manifest, checks the signature against the pinned public key, and only then fetches chunks from the CDN URLs in the manifest.
But service workers can break. When they do, you need a way out. The ?nosw=1 query parameter bypasses the service worker entirely and falls back to classic CDN loading from the static manifest.json. No delta optimization, no signature verification, but also no service-worker bug standing between the user and the application.
This is a standard escape hatch for enterprise IT debugging. It’s not a security weakness: bypassing the SW means the browser fetches directly from the CDN, which already uses HTTPS and content-hashed URLs. The signature verification is an additional layer, not the only layer.
Observability
When the service worker encounters a chunk load failure (network timeout, 404, hash mismatch), it fires a reportChunkError to POST /telemetry/chunk-error. The server increments an in-memory counter keyed by (build_id, chunk_id, error_type) and exposes it via the Prometheus endpoint at GET /metrics:
wundler_chunk_errors_total{build_id="c2a1",chunk_id="main",error_type="load_failed"} 3
A spike in load_failed errors for a specific build ID after a deploy is a signal. If the chunk hash in the signed manifest doesn’t match what’s on the CDN, every browser will report it. The monitoring catches what the signature prevents: corruption or desynchronization between the manifest and the CDN.
The property
The manifest server tells browsers where to look. The signature proves the build system approved what they’ll find there. The content hash confirms the bytes haven’t changed. Three independent checks, three independent trust boundaries. Compromise one and the other two still hold.