Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

docs/v1/security-review-2026-09-11.md

Security review — holger artifact server (2026-09-11)

Read-only review of main at 4ae7021, under holger's own threat model: an airgap-first artifact registry whose promise is that reads may be open on the secure side but writes, promotion and admin are gated, storage is immutable and content-addressed, and everything is audited. Successor to docs/security-review-2026-06-14.md; every finding that review raised is re-checked here and its status recorded.

Method. Fourteen dimensions of the attack surface were swept independently, and every finding was then handed to three refuters instructed to break it — one reading for strict correctness, one for reachability, one for impact. A finding is listed below only if at least two of the three failed to refute it. Nineteen claims did not survive and are not reported as defects; six low-value observations went unverified and are listed as such. Counts: 70 candidate findings verified, 51 survived, deduplicated to 40 distinct defects.

What could not be done. The workspace does not build on the review machine (edda main floors jera at draupnir 0.1.11, which exists in no draupnir checkout or branch here), so nothing was executed: no test run, no fuzzing, no dynamic proof. Every claim below is from reading the code, and every one cites the lines it rests on. cargo audit needs only the lockfile and did run; cargo deny needs the same resolution the build does and did not.


Executive summary

The structural problems the June review found are largely fixed. Path validation now exists and is enforced (C-1, H-1), authorization is real and per-repo (H-4), the audit log is wired at both doors (H-7), and error messages no longer leak server paths (M-3). That is genuine progress on the things that made the product pitch hollow.

What remains falls into four groups, and only the first is a surprise.

1. The per-repo Denied carve-out does not cover the cross-repo doors. Search and repository listing authorize the empty repository name — the global floor — and then read every repository. An identity explicitly denied a repository can still enumerate its coordinates, versions and archive paths, on both the gRPC and the HTTP door, and this holds in the Critical profile where the read gate is mandatory. Replication and promotion have the same shape from the other side: they authorize the destination repository and read the source with no gate at all. Denied is documented as "nothing — not even reads"; on these four paths it is not that.

2. Reads are an unauthenticated amplifier. Several read paths convert a small request into a large amount of server work: /-/search with a checksum axis reads and SHA-256s every artifact in every repository; the Deb index memoizes under a caller-chosen key so every request re-decompresses the whole pool; Gem and Conda and the Rust metadata endpoints have the same shape; a malicious Helm chart is gzip-decompressed with no output cap; and both gRPC fetch paths hold a whole artifact in memory with no concurrency bound. None of these needs a credential in the default posture.

3. A configured OIDC deployment silently stops checking audience. This is the one finding that changes a security property rather than an availability one, and it is examined in full below. In short: with the configuration every shipped guide teaches, the JWKS never resolves, the offline JWT path fails, and the provider falls back to /userinfo — which authenticates the token but checks neither issuer nor audience. A token minted for any other application at the same IdP then authenticates to holger.

4. The dependency surface has gone from clean to 29 advisories, unwatched. In June cargo audit reported zero vulnerabilities. It now reports 29 plus 18 warnings. Nothing in the repository runs cargo audit or cargo deny, there is no deny.toml, and there is no CI at all — .github/ contains only copilot-instructions.md. Nothing would have told anyone.

No arbitrary file write, no arbitrary file read, no authentication bypass and no secret leak was found. The two Critical findings from June are not reachable in the same form.


Status of the June 2026 findings

June Title Status now Evidence
C-1 file-rust::put arbitrary file write closed component validation now precedes every path build; the same shape in the marketplace is guarded by safe_tenant_id (server/lib/src/marketplace/mod.rs:343)
C-2 OIDC token never verified partly open offline JWT-vs-JWKS exists and checks iss and aud (nornir-auth-oidc/src/lib.rs verify_jwt_offline), but the documented config never reaches it — see H-3 below
H-1 file-rust::fetch arbitrary read closed server/repository/file-rust/src/lib.rs:125-127
H-2 Fail-open authentication open by design, narrowed an empty auth block still means open access (nornir-auth-policy/src/lib.rs:673-675); this is the documented airgap-mirror posture, but it is a posture an operator must choose deliberately
H-3 mTLS CN trusted without CA check open nornir-auth-mtls/src/lib.rs:112 returns a principal for any non-empty CN; trust rests entirely on the TLS layer's client_ca, which warn_unenforced_mtls_ca shouts about but does not enforce
H-4 No authorization roles closed RBAC is wired at both doors (server/lib/src/exposed/http.rs:1212-1256), with per-repo ACLs and a Denied role — but see M-1 and M-2 for where it does not reach
H-5 No archive authenticity open, now opt-in provenance verification exists but only when a repo sets ron_trusted_roots (server/lib/src/repository.rs:182); an archive without it is served unverified
H-6 Whole-artifact-in-memory OOM open server/lib/src/grpc/services.rs:427 and :147-159 — see H-4 below
H-7 No audit log closed server/lib/src/exposed/http.rs:691-703 and the gRPC record_audit choke point
H-8 TLS not enforced open server/lib/src/exposed/http.rs:136-142 still warns and serves cleartext
M-1 Decompression bomb open the sink is still uncapped (znippy-common/src/codec.rs:127-133); the Helm path reaches it — see H-2 below
M-2 Unbounded ListArtifacts open server/lib/src/exposed/http.rs:1001-1005 — limit is still unclamped
M-3 archive_info leaks a server path closed refusals now carry the frozen code and no detail (server/lib/src/marketplace/mod.rs:433, with a test)
M-4 Base images not digest-pinned open distribution/Dockerfile:18
M-5 No NetworkPolicy open deploy/k8s/namespace.yaml:4; no NetworkPolicy anywhere in the tree
M-6 NodePort exposure open deploy/k8s/service-nodeport.yaml:11
L-1 Helm parser panic closed server/repository/znippy-helm/src/index.rs:112 guards the length before indexing
L-2 Unsanitized headers closed server/lib/src/exposed/http.rs:857-866
L-3 No seccompProfile open deploy/k8s/deployment.yaml:59-63 sets the other hardening but no seccomp profile
L-4 Advisories + unsafe regressed 0 vulnerabilities in June, 29 now

Nine of twenty closed, one regressed. The closures are the structural ones.


High

H-1 · Denied does not cover enumeration: Search and ListRepositories authorize the empty repository name

server/lib/src/grpc/services.rs:1040, server/lib/src/exposed/http.rs:326

With require_auth_for_reads: true (mandatory under the Critical profile, server/lib/src/lib.rs:1494-1498) and repo_roles: {"classified": {"carol": Denied}}, Carol's direct read of classified is correctly refused. Her Search with an all-empty query is not: the rpc authorizes "", which resolves to her global role, and then searches all_repos(). The HTTP twin does the same at http.rs:326, where the gate is called with a hard-coded "" scope. She gets coordinates, versions, archive paths and a content-presence oracle for every repository she is explicitly denied.

Fix. Filter rather than gate once: authenticate, then build the repository list from all_repos() filtered by authorize_read(identity, repo).

H-2 · A malicious Helm chart OOM-kills the server on the first index.yaml request

server/repository/znippy-helm/src/index.rs:48

Index synthesis calls the uncapped gzip entry point on every chart tarball in the repository and holds them all in memory at once. A ~10 MB chart crafted near DEFLATE's 1032:1 ratio expands toward 10 GB. The trigger is an unauthenticated GET /{repo}/index.yaml, and because the result is memoized in a OnceLock the process never gets past it.

Fix. Use the capped decompression variant with a limit in the low tens of MiB; a Chart.yaml is a few KiB.

H-3 · /-/search with a checksum axis reads and hashes every artifact on the server

server/lib/src/exposed/http.rs:330, server/lib/src/search.rs:255

One unauthenticated GET /-/search?checksum=<64 hex> passes every repository into the search engine, which reads and SHA-256s every artifact to compare. limit is never clamped (http.rs:1001-1005) and is passed to the backend as a hard-coded 0. A forty-byte request becomes a full-store read-and-hash.

Fix. Clamp limit at parse time and pass it down; refuse or gate the checksum axis on the unauthenticated door.

H-4 · FetchArtifact and StreamArtifact hold whole artifacts in memory, with no concurrency cap

server/lib/src/grpc/services.rs:427 and :147-159

StreamArtifact loads the entire blob before chunking, so the chunking buys no memory bound — the June H-6 shape, unchanged. Reads are open by default (grpc.rs:438), there is no in-flight-bytes limit and no concurrency_limit_per_connection. One HTTP/2 connection's 200 streams against a 200 MB layer asks for roughly 40 GB.

Fix. Serve from a streaming backend API rather than Vec<u8>, and add a global in-flight-bytes semaphore plus a tonic concurrency limit and request timeout.

H-5 · The Deb index memoizes under a caller-chosen key

server/repository/znippy-deb/src/lib.rs:279

GET /{repo}/dists/<anything>/Release is cached under the request string, so any new path misses and re-runs a full decompress, SHA-256 and control-parse pass over every package in the pool. The attacker varies one path segment.

Fix. Key the cache on the derived identity of the work, not on the request string, and memoize the inner unit.


Medium

The seventeen medium findings, with the line each rests on. Those marked ▲ change a security property; the rest are availability or integrity-of-service.

# Finding Location
▲ M-1 The documented OIDC config never verifies a JWT. Every shipped example declares Oidc(issuer_url, audience) and leaves jwks/jwks_path/jwks_uri unset. The default then becomes {issuer}/jwks, which is wrong at Entra (/discovery/v2.0/keys), Keycloak (/protocol/openid-connect/certs), Auth0 (/.well-known/jwks.json) and Okta (/v1/keys). The offline path fails, jwks_resolvable() is false, and verify_bearer falls through to /userinfo — which authenticates the token at the IdP but checks neither issuer nor audience (the offline path checks both). Any access token the holder has for that issuer, for any other application in the tenant, authenticates to holger. The degrade is silent: no warning, no boot error. nornir-auth-oidc/src/lib.rs:364, :84
▲ M-2 Replicate and PromoteArtifact authorize only the destination. The source repository is read with no gate, so an admin on one repository exfiltrates from a repository they are denied, in one RPC. server/lib/src/grpc/services.rs:1452
▲ M-3 SetProperty is plain writer-gated, with no reserved-key list — a Writer can clear the quarantine flag and forge the signed property the promotion gate trusts. server/lib/src/grpc/services.rs:1299
▲ M-4 The serve-time quarantine gate is inert on the HTTP door for every shipped backend except raw: when coordinate_for_path returns None the gate falls open. gRPC refuses the same artifact. server/lib/src/exposed/http.rs:1286
▲ M-5 The pull-through cache writes unverified upstream bytes, then re-attests them with its own SHA-256 in the sparse index — defeating cargo's end-to-end check rather than extending it. server/lib/src/proxy.rs:224
▲ M-6 A writer can permanently substitute an upstream crate in a cache repository, because the documented cache is one archive that is both writable and a proxy, and local entries win. server/repository/znippy-rust/src/lib.rs:624
▲ M-7 The upstream client follows redirects with no origin re-validation, turning a hostile upstream into SSRF whose response is returned to an anonymous client and cached. server/lib/src/upstream_rust.rs:31
▲ M-8 The agent parses the Cargo.lock checksum and the PyPI sha256, then discards both. The airgap bundle and its provenance receipt attest bytes nobody verified against the coordinate's published digest. agent/connector/world/src/lib.rs:116
▲ M-9 Property and SBOM writes authorize on the raw repository string but store under a sanitized one, and never check the repository exists. server/lib/src/properties.rs:60
▲ M-10 npm airgap ingestion runs package lifecycle scripts on the bridge that holds the holger write credential (no --ignore-scripts). Reachable only by an operator running the population scaffolding, not by a server attacker. distribution/airgap/npm/build-and-push.sh:23
M-11 Every read buffers an attacker-chosen request body up to 1 GiB before dispatch; the collect is not guarded by is_read. server/lib/src/exposed/http.rs:830
M-12 gRPC read handlers run blocking backend I/O — including outbound upstream HTTP — directly on tokio worker threads, unlike the HTTP door which wraps the same call in spawn_blocking. server/lib/src/grpc/services.rs:159
M-13 The marketplace landing door grows a never-evicted process-global JWKS cache keyed by an attacker-chosen tid from an unverified token, and fetches upstream keys once per distinct value. server/lib/src/marketplace/signin.rs:254
M-14 OCI blob-upload staging files are unbounded and never reclaimed, and finalize reads the whole accumulated file into memory. server/repository/file-oci/src/lib.rs:180
M-15 Conda repodata.json is cached under the caller-supplied subdir, so the cache grows without bound and can be bypassed. server/repository/znippy-conda/src/lib.rs:141
M-16 Gem compact-index info/{name} re-decompresses and re-hashes every version on every request, with no cache at all. server/repository/znippy-gem/src/lib.rs:115
M-17 Rust metadata endpoints re-read, re-decompress and re-hash every stored version on every request. server/repository/znippy-rust/src/lib.rs:470

Low

Eighteen confirmed low findings, in brief. Each was verified the same way.

Six further observations were reported without verification and are recorded for completeness, not as findings: WhoAmI as an ungated credential-validation oracle; the Docker manifest Content-Type echoed from the manifest's own mediaType; the bundle-import audit record written after the import commits; OCI upload session ids as a process counter; the bundle decompression guard allocating before it checks the cap; and the wasm plugin host running guest code with no time or memory bound.


Dependencies

cargo audit on Cargo.lock: 29 vulnerabilities, 18 warnings, against zero in June. Full output was captured during the review.

The process gap matters more than any single advisory: nothing watches this. There is no CI (.github/ holds only copilot-instructions.md), nothing in the tree invokes cargo audit or cargo deny, and there is no deny.toml. The release procedure in CLAUDE.md is entirely manual and does not mention either tool. Three months of drift went unremarked.

No secret is committed. deploy/k8s/secret.yaml is a placeholder template that recommends cert-manager or SOPS; the private-key string in server/cli/src/main.rs is a test fixture. Historical test certificates from the removed holger-mannequin/ and holger-ron/ paths remain in git history; mannequin now generates its CA in memory, so nothing in the tree uses them.


What to fix first

Ranked by risk reduced per unit of work.

  1. Filter the cross-repo doors by role instead of gating once on "". Closes H-1 and M-2 together, and restores the meaning of Denied. Four call sites.
  2. Make an OIDC method with no resolvable JWKS a boot error, and correct the default jwks_uri or remove the default entirely. Closes M-1 — the one finding that lets a token from elsewhere authenticate — and finishes June's C-2. One condition at boot plus a documentation pass over the five guides that teach the incomplete form.
  3. Bound the read paths. Clamp limit and pass it down; cap the Helm decompression; key the Deb and Conda caches on derived identity; add a concurrency and in-flight-bytes limit to the gRPC serve tail. Closes H-2, H-3, H-5 and much of H-4.
  4. Wire cargo audit and cargo deny into something that runs, with a deny.toml, and record the wasmtime advisories as accepted-while-the-feature-is-off rather than leaving them unexplained. The gap that let 0 become 29 is the absence of a watcher, not any one crate.
  5. Give the quarantine gate and the reserved property keys a floor. Make serve_quarantined fail closed when it cannot map a path to a coordinate, and require admin for the keys the gates trust. Closes M-3 and M-4.

Method note

Fourteen dimensions were swept: the HTTP door, the marketplace seam, the gRPC surface, the authorization model, persistence, the Rust/JVM/Python backends, the OS and OCI backends, the proxy and upstreams, the update bundle, crypto and secrets, DoS and resource bounds, supply chain, configuration and fail-closed behaviour, and the airgap and appliance lane. 224 agents, 70 candidate findings, three refuters each on everything above low severity.

Nineteen candidate findings were refuted and are deliberately absent. They included several that would have read as serious: that a per-repo Denied is a no-op for reads; that property removal is reachable with the Writer role; that the marketplace doors serve over cleartext; that random_hex fails open to a clock-derived value; that the ISO installer enrols a published development seed as the fleet root of trust; and that the release container ignores the audited lockfile. Each was checked against the code and did not hold. They are listed here because a reader who later finds one of them independently should know it was examined and dismissed, not missed.


Fix status (2026-09-11, branch fix/security-review)

All 40 distinct defects are closed. Every fix carries a test that was proven RED: the production change was reverted, the test was run and observed to fail, and only then was the change restored. A test that passes with and without its fix proves nothing and none were accepted.

The workspace also builds and runs now, which the review itself could not do. The constellation gap it describes was worked around with a scratchpad worktree beside symlinked siblings, so the claims below are executed, not read: 973 tests pass across 76 targets, 0 failures (excluding holger-mannequin and holger-agent-cli, which do not compile for a pre-existing reason unrelated to this work — facett's egui 0.34 against the workspace's 0.35 — and bench-scenarios, whose art_probe and retention_probe examples are missing required-features declarations, also pre-existing).

Where the fixes changed the shape of the product

Six fixes do more than close a hole, and an operator will notice them:

A method whose only key source is jwks_uri is treated separately, and boots with a loud warning instead. That hazard is conditional rather than certain — a correct, reachable endpoint does verify and does pin issuer and audience, and it degrades only when the fetch yields nothing — and refusing it would refuse holger's own marketplace tenants, every one of which is provisioned with exactly that shape. A control that refuses the product's primary configuration is a control that gets switched off.

A group can only GRANT, never deny — group roles combine by taking the highest — so a group_roles entry set to Denied reads as a denial and is not one. It is shouted about at boot rather than silently honoured as nothing.

The fixes were then reviewed the same way

Before merging, the commit itself was swept by six independent lenses — does each fix close its hole, what legitimate deployment does it break, did parallel workers contradict each other, do the tests assert what they claim, is the prose true, what breaks for consumers — and every candidate finding was put to three refuters with distinct angles, surviving only on a majority. Twenty-four candidates, five survived, all five fixed here:

Deliberately not done