From b576c6166d7e50181e053730134d2af41d364037 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:44:13 +0000 Subject: [PATCH] Docker: challenge a request without credentials for an image a selector hides Since #703, an image a content selector hides from a caller who may read the repository answers 404 NAME_UNKNOWN, ahead of the arm that challenges an unauthenticated caller. So a request that sent no credential of its own (anonymous under anonymous mode `all`, or an account a trusted-header realm names) got 404 and no WWW-Authenticate. containerd, and with it a Kubernetes pull with an imagePullSecret, sends its credentials only after a 401: the pull failed with "not found" where 2.1 challenged it and the credentials pulled the image. `refusal` now answers a hidden image with the Bearer challenge when the request presented no credential and no registry token. A request with a registry token, an anonymous one included, still finds no such image, as #703 intends. #703's changelog fragment and docs/docker.md say so. Fixes #787 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJc1H3748D8sheqhKTV --- crates/server/src/images/dispatch.rs | 9 +- crates/server/tests/registry.rs | 148 +++++++++++++++++++++++++++ docs/changes/2.2.0/703.fixed.md | 7 +- docs/docker.md | 5 +- 4 files changed, 165 insertions(+), 4 deletions(-) diff --git a/crates/server/src/images/dispatch.rs b/crates/server/src/images/dispatch.rs index 4588ae8c..b026f8f5 100644 --- a/crates/server/src/images/dispatch.rs +++ b/crates/server/src/images/dispatch.rs @@ -575,7 +575,11 @@ impl Registry { /// credentials or a token for the scope could help, a denial (403) for /// an authenticated caller the permission is missing for, and for an /// image a selector hides the answer to a name that does not exist - /// (404 `NAME_UNKNOWN`). + /// (404 `NAME_UNKNOWN`). A request that presented no credential of its + /// own (anonymous, or an ambient session or trusted-header account) is + /// challenged for a hidden image instead: containerd, and so Kubernetes, + /// sends its credentials only after a 401, and the image may be its + /// credentials' to pull (#787). pub(crate) fn refusal( &self, caller: &Caller, @@ -587,6 +591,9 @@ impl Registry { let authenticated = mutation_credential(&caller.principal); let error = caller.claims.as_ref().map(|_| "insufficient_scope"); match decision { + Decision::Hidden if !authenticated && caller.claims.is_none() => { + challenge(site, name, permission, None) + } Decision::Hidden => Failure::new(ImageError::NameUnknown { name: name.to_string(), }), diff --git a/crates/server/tests/registry.rs b/crates/server/tests/registry.rs index 8546908c..08e273e8 100644 --- a/crates/server/tests/registry.rs +++ b/crates/server/tests/registry.rs @@ -3645,6 +3645,154 @@ async fn an_image_a_selector_hides_is_not_found() { assert_eq!(oci.get(&hidden, "manifests/1").await.status(), 403); } +/// A request that sent no credential of its own is challenged for an image +/// a selector hides (#787), as in 2.1: containerd, and so a Kubernetes pull +/// with an imagePullSecret, sends its credentials only after a 401, and the +/// image may be theirs to pull. Here the anonymous role and a trusted-header +/// account read the repository through a selector that hides `secret/app`, +/// which Alice may pull. A request with a registry token, an anonymous one +/// included, still finds no such image (#703). +#[tokio::test] +async fn a_hidden_image_challenges_a_request_without_credentials() { + const SECRET: &str = "the-proxys-secret-0123456789abcdef"; + let srv = common::start_with( + common::fresh_pool().await, + common::ServerOpts { + bootstrap_admin: Some((format!("admin{}@example.com", unique()), "adminpw".into())), + auth_extra: Some(r#"trusted_proxy_cidrs = ["127.0.0.1"]"#.into()), + server_extra: Some(r#"allowed_hosts = ["127.0.0.1"]"#.into()), + ..Default::default() + }, + ) + .await; + srv.set_auth_settings(json!({"anonymous_mode": "all"})) + .await; + let (u, p) = srv.admin_basic(); + let admin_send = |method: Method, path: &str, body: Value| { + srv.client() + .request(method, format!("{}{path}", srv.base_url)) + .basic_auth(&u, Some(&p)) + .json(&body) + .send() + }; + + let repo = repo_name("sel"); + create_repo( + &srv, + json!({"name": repo, "kind": "hosted", "format": "docker"}), + ) + .await; + let admin = admin_oci(&srv).await; + admin + .push_image(&format!("{repo}/secret/app"), "1", "amd64", b"secret layer") + .await; + admin + .push_image(&format!("{repo}/public/app"), "1", "amd64", b"public layer") + .await; + let selector = srv.create_selector(&["public/*"]).await; + + // The anonymous role reads `public/*` of the repository. + let anonymous = forge_db::authz_dao::role_id_by_name(&srv.pool, "anonymous") + .await + .unwrap() + .expect("anonymous role seeded"); + let resp = admin_send( + Method::PUT, + &format!("/admin/roles/{anonymous}/grants"), + json!({"repository": repo, "level": "read", "selector_id": selector}), + ) + .await + .unwrap(); + assert!(resp.status().is_success(), "{}", resp.text().await.unwrap()); + + // So does an account a trusted-header realm names. + let n = unique(); + let (user_header, secret_header) = (format!("X-User-{n}"), format!("X-Proxy-Secret-{n}")); + let realm = format!("proxy-{n}"); + let resp = admin_send( + Method::POST, + "/admin/realms", + json!({ + "kind": "trusted_header", + "name": realm, + "config": { + "username_header": user_header, + "secret_header": secret_header, + "secret": SECRET, + "auto_provision": false, + }, + }), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 201, "{}", resp.text().await.unwrap()); + let office = format!("office{n}@example.com"); + let resp = admin_send( + Method::POST, + "/admin/users", + json!({"username": office, "realm": realm, "role": "roles_only"}), + ) + .await + .unwrap(); + let body: Value = resp.json().await.unwrap(); + let office_id = body["id"].as_i64().expect("account created"); + srv.grant_role(office_id, &[(&repo, "read", Some(selector))]) + .await; + + // Alice may pull every image of the repository. + let alice = srv.caller().await; + srv.grant_role(alice.id, &[(&repo, "read", None)]).await; + + let hidden = format!("{repo}/secret/app"); + let url = format!("{}/v2/{hidden}/manifests/1", srv.base_url); + for (who, headers) in [ + ("anonymous", vec![]), + ( + "the trusted-header account", + vec![ + (secret_header.as_str(), SECRET), + (user_header.as_str(), office.as_str()), + ], + ), + ] { + for method in [Method::GET, Method::HEAD] { + let mut req = srv.client().request(method.clone(), &url); + for (name, value) in &headers { + req = req.header(*name, *value); + } + let resp = req.send().await.unwrap(); + assert_eq!(resp.status(), 401, "{who}: {method} of a hidden image"); + let challenge = resp.headers()[WWW_AUTHENTICATE].to_str().unwrap(); + assert!(challenge.starts_with("Bearer realm="), "{who}: {challenge}"); + assert!( + challenge.contains(&format!("scope=\"repository:{hidden}:pull\"")), + "{who}: {challenge}" + ); + } + // What the selector shows is served as before. + let mut req = srv + .client() + .get(format!("{}/v2/{repo}/public/app/manifests/1", srv.base_url)); + for (name, value) in &headers { + req = req.header(*name, *value); + } + assert_eq!(req.send().await.unwrap().status(), 200, "{who}: public/app"); + } + + // With the anonymous token a credential-less client then gets, the + // hidden image is not found. + let resp = Oci::new(&srv, None).get(&hidden, "manifests/1").await; + assert_eq!(resp.status(), 404); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["errors"][0]["code"], "NAME_UNKNOWN", "{body}"); + + // With Alice's credentials, sent after the challenge, the pull works. + let resp = Oci::new(&srv, Some((alice.username, alice.token))) + .get(&hidden, "manifests/1") + .await; + assert_eq!(resp.status(), 200); +} + /// A group shows a caller what it could pull from its members directly, no /// more (#648): with a member it may not read, a selector on the group's /// grant or one on a member's, a hidden image's manifests, blobs, tags and diff --git a/docs/changes/2.2.0/703.fixed.md b/docs/changes/2.2.0/703.fixed.md index 3c4f99d4..2bf687c1 100644 --- a/docs/changes/2.2.0/703.fixed.md +++ b/docs/changes/2.2.0/703.fixed.md @@ -3,5 +3,8 @@ was refused with 403 (`DENIED`, 401 for an anonymous caller) when the selector was on the repository pulled from, a group's included; through a group's member it answered 404. Such an image is now not found on every endpoint (manifests, blobs, tags and referrers answer 404 `NAME_UNKNOWN`), -as a package a selector hides is in the other formats. A push or a delete -outside the selector is still refused with 403. +as a package a selector hides is in the other formats. A request that sends +no credentials of its own (anonymous, or an account a trusted-header realm +names) still gets the 401 challenge, so a client that sends its credentials +only then, as containerd and Kubernetes do, pulls such an image with them. A +push or a delete outside the selector is still refused with 403. diff --git a/docs/docker.md b/docs/docker.md index 7722d4fe..57583789 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -111,7 +111,10 @@ needs `delete`; content selectors match image names (`team/*`). An image a selector hides from a caller who may read the repository is not found: a pull, its tags or its referrers answer 404 `NAME_UNKNOWN`, as for a name that does not exist (also through a group), while a push or a delete of it -is refused with 403. One token +is refused with 403. A request that sends no credentials of its own +(anonymous, or an account a trusted-header realm names) gets the 401 +challenge for it instead, since containerd and Kubernetes send their +credentials only after one. One token request may name at most 32 repositories (clients ask for one to three); a request naming more is refused with `400 UNSUPPORTED`. Every request is authorized again against the current permissions, and the user