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