---
title: "A Compromised Server Can't Inject Code"
date: 2026-05-28
type: post
tags: [bundler, security, signing, cloudpack, threat-model]
summary: "The manifest server tells browsers which chunks to fetch. If an attacker owns it, they can redirect to existing content-hashed chunks. They can't inject new code. The signature covers the chunk hashes, not the advisory fields."
---

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`](https://github.com/kenotron-ms/cloudpack/blob/main/crates/wundler-abs/src/signing.rs):

```rust
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.

```rust
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.

<div class="breakout">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 400" role="img" aria-label="Diagram: What's Signed vs What's Advisory in the chunk manifest">
  <rect width="840" height="400" fill="#F5F0E8" rx="4"/>
  <text x="420" y="32" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#2B2421">What's Signed vs What's Advisory</text>
  <rect x="40" y="56" width="360" height="300" rx="4" fill="none" stroke="#2B2421" stroke-width="1.5"/>
  <text x="220" y="84" text-anchor="middle" font-family="Inter, sans-serif" font-size="13" font-weight="600" fill="#2B2421" letter-spacing="0.05em">SIGNED (integrity)</text>
  <text x="220" y="104" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D" font-style="italic">covered by ed25519 signature</text>
  <rect x="60" y="120" width="320" height="40" rx="3" fill="#2B2421" stroke="none"/>
  <text x="220" y="145" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#F5F0E8">build_id: "c2a1f8..."</text>
  <rect x="60" y="170" width="320" height="40" rx="3" fill="#2B2421" stroke="none"/>
  <text x="220" y="195" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#F5F0E8">chunk "main" hash: "4f1a..."</text>
  <rect x="60" y="220" width="320" height="40" rx="3" fill="#2B2421" stroke="none"/>
  <text x="220" y="245" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#F5F0E8">chunk "vendor" hash: "7e3b..."</text>
  <rect x="60" y="270" width="320" height="40" rx="3" fill="#2B2421" stroke="none"/>
  <text x="220" y="295" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#F5F0E8">chunk "settings" hash: "9d1c..."</text>
  <text x="220" y="340" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#2B2421">Change any hash = signature invalid.</text>
  <rect x="440" y="56" width="360" height="300" rx="4" fill="none" stroke="#DDD7CD" stroke-width="1.5"/>
  <text x="620" y="84" text-anchor="middle" font-family="Inter, sans-serif" font-size="13" font-weight="600" fill="#6B5F55" letter-spacing="0.05em">ADVISORY (optimization)</text>
  <text x="620" y="104" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#948880" font-style="italic">excluded from signature</text>
  <rect x="460" y="120" width="320" height="36" rx="3" fill="none" stroke="#DDD7CD" stroke-width="1" stroke-dasharray="5,3"/>
  <text x="620" y="143" text-anchor="middle" font-family="monospace" font-size="11" fill="#948880">co_request_score: 0.87</text>
  <rect x="460" y="166" width="320" height="36" rx="3" fill="none" stroke="#DDD7CD" stroke-width="1" stroke-dasharray="5,3"/>
  <text x="620" y="189" text-anchor="middle" font-family="monospace" font-size="11" fill="#948880">median_load_order: 2</text>
  <rect x="460" y="212" width="320" height="36" rx="3" fill="none" stroke="#DDD7CD" stroke-width="1" stroke-dasharray="5,3"/>
  <text x="620" y="235" text-anchor="middle" font-family="monospace" font-size="11" fill="#948880">suggested_merge: "chunk-7"</text>
  <text x="620" y="280" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#6B5F55">PGO pipeline writes these</text>
  <text x="620" y="300" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#6B5F55">without a rebuild.</text>
  <text x="620" y="340" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#6B5F55">Change freely. Signature still valid.</text>
</svg>
</div>

## 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

<div class="breakout">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 420" role="img" aria-label="Diagram: Threat model showing what an attacker can and cannot do with a compromised manifest server">
  <rect width="840" height="420" fill="#F5F0E8" rx="4"/>
  <text x="420" y="32" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#2B2421">The Threat Model</text>
  <rect x="40" y="56" width="360" height="320" rx="4" fill="none" stroke="#DDD7CD" stroke-width="1.5"/>
  <text x="220" y="84" text-anchor="middle" font-family="Inter, sans-serif" font-size="13" font-weight="600" fill="#6B5F55" letter-spacing="0.05em">ATTACKER CAN</text>
  <text x="220" y="104" text-anchor="middle" font-family="Georgia, serif" font-size="11" fill="#948880" font-style="italic">with compromised manifest server</text>
  <rect x="60" y="120" width="320" height="36" rx="3" fill="none" stroke="#DDD7CD" stroke-width="1"/>
  <text x="220" y="143" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#6B5F55">Redirect to existing CDN chunks</text>
  <rect x="60" y="164" width="320" height="36" rx="3" fill="none" stroke="#DDD7CD" stroke-width="1"/>
  <text x="220" y="187" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#6B5F55">Serve an older signed manifest</text>
  <rect x="60" y="208" width="320" height="36" rx="3" fill="none" stroke="#DDD7CD" stroke-width="1"/>
  <text x="220" y="231" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#6B5F55">Change PGO advisory fields</text>
  <rect x="60" y="252" width="320" height="36" rx="3" fill="none" stroke="#DDD7CD" stroke-width="1"/>
  <text x="220" y="275" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#6B5F55">Deny service (return errors)</text>
  <text x="220" y="320" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#948880">Impact: version pinning, prefetch</text>
  <text x="220" y="340" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#948880">reordering, downgrade to older build.</text>
  <text x="220" y="360" text-anchor="middle" font-family="Georgia, serif" font-size="12" font-weight="bold" fill="#6B5F55">No code execution.</text>
  <rect x="440" y="56" width="360" height="320" rx="4" fill="none" stroke="#2B2421" stroke-width="1.5"/>
  <text x="620" y="84" text-anchor="middle" font-family="Inter, sans-serif" font-size="13" font-weight="600" fill="#A0522D" letter-spacing="0.05em">ATTACKER CANNOT</text>
  <text x="620" y="104" text-anchor="middle" font-family="Georgia, serif" font-size="11" fill="#A0522D" font-style="italic">blocked by signature + content hashing</text>
  <rect x="460" y="120" width="320" height="36" rx="3" fill="#A0522D" fill-opacity="0.1" stroke="#A0522D" stroke-width="1"/>
  <text x="620" y="143" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">Inject new JavaScript</text>
  <rect x="460" y="164" width="320" height="36" rx="3" fill="#A0522D" fill-opacity="0.1" stroke="#A0522D" stroke-width="1"/>
  <text x="620" y="187" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">Modify a chunk's contents</text>
  <rect x="460" y="208" width="320" height="36" rx="3" fill="#A0522D" fill-opacity="0.1" stroke="#A0522D" stroke-width="1"/>
  <text x="620" y="231" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">Forge a valid signature</text>
  <rect x="460" y="252" width="320" height="36" rx="3" fill="#A0522D" fill-opacity="0.1" stroke="#A0522D" stroke-width="1"/>
  <text x="620" y="275" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">Create a chunk with a valid hash</text>
  <text x="620" y="320" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">Requires: build system signing key</text>
  <text x="620" y="340" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">+ CDN write access</text>
  <text x="620" y="360" text-anchor="middle" font-family="Georgia, serif" font-size="12" font-weight="bold" fill="#A0522D">Two separate breaches needed.</text>
</svg>
</div>

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](https://github.com/kenotron-ms/cloudpack/blob/main/crates/wundler-abs/src/server.rs):

```rust
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:

```rust
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.