Skip to content

Commit cd2df3f

Browse files
authored
docker-gateway: Allow image pull from registry (#74)
1 parent 92e0896 commit cd2df3f

File tree

1 file changed

+230
-0
lines changed
  • runtime/docker-gateway/src

1 file changed

+230
-0
lines changed

runtime/docker-gateway/src/lib.rs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,28 @@ async fn handle(mut req: Request<ProxyBody>, conn: Arc<ConnState>) -> Response<P
623623
return forward(req, conn.state.clone()).await;
624624
}
625625

626+
// Allow image pull (POST /images/create?fromImage=...) but block image import (fromSrc)
627+
// and bare requests with no query parameters.
628+
if req.method() == Method::POST && is_create_endpoint(&segs, "images") {
629+
let is_pull = req
630+
.uri()
631+
.query()
632+
.and_then(|q| serde_urlencoded::from_str::<Vec<(String, String)>>(q).ok())
633+
.map(|params| {
634+
params.iter().any(|(k, _)| k == "fromImage")
635+
&& !params.iter().any(|(k, _)| k == "fromSrc")
636+
})
637+
.unwrap_or(false);
638+
639+
if is_pull {
640+
return forward(req, conn.state.clone()).await;
641+
}
642+
return docker_error(
643+
StatusCode::FORBIDDEN,
644+
"only image pull (fromImage) is allowed; build/import is blocked",
645+
);
646+
}
647+
626648
if requires_owner_label_filters(req.method(), &segs) {
627649
if let Err(resp) = apply_required_label_filters(&mut req, &conn.state, &id) {
628650
return *resp;
@@ -2948,6 +2970,162 @@ mod tests {
29482970
assert_eq!(requests[0].path, "/images/ghcr.io/org/app:latest/json");
29492971
}
29502972

2973+
#[tokio::test(flavor = "multi_thread")]
2974+
async fn allows_image_pull_with_from_image() {
2975+
let gateway = GatewayHarness::start(vec![default_caller()]).await;
2976+
gateway.enqueue_json(
2977+
Method::POST,
2978+
"/images/create",
2979+
StatusCode::OK,
2980+
serde_json::json!({"status":"Pulling from library/nginx"}),
2981+
);
2982+
2983+
let response = send_gateway_request(
2984+
gateway.addr,
2985+
Method::POST,
2986+
"/images/create?fromImage=nginx&tag=latest",
2987+
&[],
2988+
&[],
2989+
)
2990+
.await;
2991+
assert_eq!(response.status, StatusCode::OK);
2992+
2993+
let requests = gateway.requests();
2994+
assert_eq!(requests.len(), 1);
2995+
assert_eq!(requests[0].method, Method::POST);
2996+
assert_eq!(requests[0].path, "/images/create");
2997+
assert_eq!(
2998+
requests[0].path_and_query,
2999+
"/images/create?fromImage=nginx&tag=latest"
3000+
);
3001+
}
3002+
3003+
#[tokio::test(flavor = "multi_thread")]
3004+
async fn allows_image_pull_without_explicit_tag() {
3005+
let gateway = GatewayHarness::start(vec![default_caller()]).await;
3006+
gateway.enqueue_json(
3007+
Method::POST,
3008+
"/images/create",
3009+
StatusCode::OK,
3010+
serde_json::json!({"status":"Pulling from library/alpine"}),
3011+
);
3012+
3013+
let response = send_gateway_request(
3014+
gateway.addr,
3015+
Method::POST,
3016+
"/images/create?fromImage=alpine",
3017+
&[],
3018+
&[],
3019+
)
3020+
.await;
3021+
assert_eq!(response.status, StatusCode::OK);
3022+
3023+
let requests = gateway.requests();
3024+
assert_eq!(requests.len(), 1);
3025+
assert_eq!(requests[0].method, Method::POST);
3026+
assert_eq!(
3027+
requests[0].path_and_query,
3028+
"/images/create?fromImage=alpine"
3029+
);
3030+
}
3031+
3032+
#[tokio::test(flavor = "multi_thread")]
3033+
async fn allows_image_pull_with_registry_qualified_name() {
3034+
let gateway = GatewayHarness::start(vec![default_caller()]).await;
3035+
gateway.enqueue_json(
3036+
Method::POST,
3037+
"/images/create",
3038+
StatusCode::OK,
3039+
serde_json::json!({"status":"Pulling from ghcr.io/org/app"}),
3040+
);
3041+
3042+
let response = send_gateway_request(
3043+
gateway.addr,
3044+
Method::POST,
3045+
"/images/create?fromImage=ghcr.io/org/app&tag=v1.2",
3046+
&[],
3047+
&[],
3048+
)
3049+
.await;
3050+
assert_eq!(response.status, StatusCode::OK);
3051+
3052+
let requests = gateway.requests();
3053+
assert_eq!(requests.len(), 1);
3054+
assert_eq!(
3055+
requests[0].path_and_query,
3056+
"/images/create?fromImage=ghcr.io/org/app&tag=v1.2"
3057+
);
3058+
}
3059+
3060+
#[tokio::test(flavor = "multi_thread")]
3061+
async fn blocks_image_import_with_from_src() {
3062+
let gateway = GatewayHarness::start(vec![default_caller()]).await;
3063+
3064+
let response = send_gateway_request(
3065+
gateway.addr,
3066+
Method::POST,
3067+
"/images/create?fromSrc=-",
3068+
&[("content-type", "application/x-tar")],
3069+
&[],
3070+
)
3071+
.await;
3072+
assert_eq!(response.status, StatusCode::FORBIDDEN);
3073+
assert!(response_message(&response.body).contains("only image pull"));
3074+
assert!(gateway.requests().is_empty());
3075+
}
3076+
3077+
#[tokio::test(flavor = "multi_thread")]
3078+
async fn blocks_image_create_with_no_query_params() {
3079+
let gateway = GatewayHarness::start(vec![default_caller()]).await;
3080+
3081+
let response =
3082+
send_gateway_request(gateway.addr, Method::POST, "/images/create", &[], &[]).await;
3083+
assert_eq!(response.status, StatusCode::FORBIDDEN);
3084+
assert!(response_message(&response.body).contains("only image pull"));
3085+
assert!(gateway.requests().is_empty());
3086+
}
3087+
3088+
#[tokio::test(flavor = "multi_thread")]
3089+
async fn blocks_image_create_with_both_from_image_and_from_src() {
3090+
let gateway = GatewayHarness::start(vec![default_caller()]).await;
3091+
3092+
let response = send_gateway_request(
3093+
gateway.addr,
3094+
Method::POST,
3095+
"/images/create?fromImage=nginx&fromSrc=-",
3096+
&[],
3097+
&[],
3098+
)
3099+
.await;
3100+
assert_eq!(response.status, StatusCode::FORBIDDEN);
3101+
assert!(gateway.requests().is_empty());
3102+
}
3103+
3104+
#[tokio::test(flavor = "multi_thread")]
3105+
async fn allows_image_pull_with_version_prefix() {
3106+
let gateway = GatewayHarness::start(vec![default_caller()]).await;
3107+
gateway.enqueue_json(
3108+
Method::POST,
3109+
"/v1.45/images/create",
3110+
StatusCode::OK,
3111+
serde_json::json!({"status":"Pulling from library/nginx"}),
3112+
);
3113+
3114+
let response = send_gateway_request(
3115+
gateway.addr,
3116+
Method::POST,
3117+
"/v1.45/images/create?fromImage=nginx&tag=latest",
3118+
&[],
3119+
&[],
3120+
)
3121+
.await;
3122+
assert_eq!(response.status, StatusCode::OK);
3123+
3124+
let requests = gateway.requests();
3125+
assert_eq!(requests.len(), 1);
3126+
assert_eq!(requests[0].method, Method::POST);
3127+
}
3128+
29513129
#[tokio::test(flavor = "multi_thread")]
29523130
async fn allows_info_endpoint() {
29533131
let gateway = GatewayHarness::start(vec![default_caller()]).await;
@@ -4186,6 +4364,58 @@ mod tests {
41864364
.await;
41874365
assert_eq!(blocked_connect.status, StatusCode::FORBIDDEN);
41884366

4367+
// Image pull: resolve a known image, then pull it through the gateway.
4368+
if let Some(image) = docker_image_for_ignored_e2e(&docker_sock).await {
4369+
let (from_image, tag) = image.rsplit_once(':').unwrap_or((image.as_str(), "latest"));
4370+
4371+
let pull_result = send_gateway_request(
4372+
listen,
4373+
Method::POST,
4374+
&format!("/images/create?fromImage={from_image}&tag={tag}"),
4375+
&[],
4376+
&[],
4377+
)
4378+
.await;
4379+
assert!(
4380+
pull_result.status.is_success(),
4381+
"image pull via gateway failed: {} {}",
4382+
pull_result.status,
4383+
String::from_utf8_lossy(&pull_result.body)
4384+
);
4385+
4386+
// Verify the pulled image is inspectable through the gateway.
4387+
let inspect = send_gateway_request(
4388+
listen,
4389+
Method::GET,
4390+
&format!("/images/{image}/json"),
4391+
&[],
4392+
&[],
4393+
)
4394+
.await;
4395+
assert_eq!(
4396+
inspect.status,
4397+
StatusCode::OK,
4398+
"image inspect after pull failed: {}",
4399+
String::from_utf8_lossy(&inspect.body)
4400+
);
4401+
}
4402+
4403+
// Image import should still be blocked.
4404+
let blocked_import = send_gateway_request(
4405+
listen,
4406+
Method::POST,
4407+
"/images/create?fromSrc=-",
4408+
&[("content-type", "application/x-tar")],
4409+
&[],
4410+
)
4411+
.await;
4412+
assert_eq!(blocked_import.status, StatusCode::FORBIDDEN);
4413+
4414+
// Bare image create (no query params) should be blocked.
4415+
let blocked_bare =
4416+
send_gateway_request(listen, Method::POST, "/images/create", &[], &[]).await;
4417+
assert_eq!(blocked_bare.status, StatusCode::FORBIDDEN);
4418+
41894419
gateway_task.abort();
41904420
docker_delete_network_best_effort(&docker_sock, &owned_network).await;
41914421
docker_delete_network_best_effort(&docker_sock, &foreign_network).await;

0 commit comments

Comments
 (0)