From 80e418dc3c1d0ca24e15a953ac9b2824b0a30bb7 Mon Sep 17 00:00:00 2001 From: ilyasse benrkia Date: Mon, 21 Sep 2026 16:28:39 +0000 Subject: [PATCH 1/4] fix: bypass proxy for Runtime API calls The Runtime API client used Net::HTTP.post and Net::HTTP.new(host, port), both of which default to :ENV proxy resolution and honor HTTP(S)_PROXY. A customer-configured proxy then routed calls to the API endpoint through the proxy, so a proxy in the environment could break function init and result delivery even though it should never affect communication with the API. Route the invocation poll and the three write-backs through a single client built with a nil proxy argument, which disables Net::HTTP's default proxy resolution so the link-local API endpoint is never proxied. --- lib/aws_lambda_ric/lambda_server.rb | 23 +++++- test/unit/lambda_server_test.rb | 109 ++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/lib/aws_lambda_ric/lambda_server.rb b/lib/aws_lambda_ric/lambda_server.rb index b4e0070..9da0154 100644 --- a/lib/aws_lambda_ric/lambda_server.rb +++ b/lib/aws_lambda_ric/lambda_server.rb @@ -20,7 +20,7 @@ def initialize(server_address, user_agent) def next_invocation next_invocation_uri = URI(@server_address + '/runtime/invocation/next') begin - http = Net::HTTP.new(next_invocation_uri.host, next_invocation_uri.port) + http = build_client(next_invocation_uri) http.read_timeout = LONG_TIMEOUT_MS resp = http.start do |connection| connection.get(next_invocation_uri.path, { 'User-Agent' => @user_agent }) @@ -49,7 +49,7 @@ def send_response(request_id:, response_object:, content_type: 'application/json if content_type == 'application/unknown' response_object = response_object.read end - Net::HTTP.post( + post( response_uri, response_object, { 'Content-Type' => content_type, 'User-Agent' => @user_agent } @@ -64,7 +64,7 @@ def send_error_response(request_id:, error_object:, error:, xray_cause:) begin headers = { 'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type, 'User-Agent' => @user_agent } headers['Lambda-Runtime-Function-XRay-Error-Cause'] = xray_cause if xray_cause.bytesize < MAX_HEADER_SIZE_BYTES - Net::HTTP.post( + post( response_uri, error_object.to_json, headers @@ -77,7 +77,7 @@ def send_error_response(request_id:, error_object:, error:, xray_cause:) def send_init_error(error_object:, error:) uri = URI("#{@server_address}/runtime/init/error") begin - Net::HTTP.post( + post( uri, error_object.to_json, { 'Lambda-Runtime-Function-Error-Type' => error.runtime_error_type, 'User-Agent' => @user_agent } @@ -86,4 +86,19 @@ def send_init_error(error_object:, error:) raise LambdaErrors::LambdaRuntimeInitError.new(e) end end + + private + + # The Runtime API endpoint must never be proxied. The nil proxy argument + # disables Net::HTTP's default :ENV proxy resolution, which would otherwise + # route calls through a customer-configured PROXY. + def build_client(uri) + Net::HTTP.new(uri.host, uri.port, nil) + end + + def post(uri, body, headers) + build_client(uri).start do |connection| + connection.post(uri.path, body, headers) + end + end end diff --git a/test/unit/lambda_server_test.rb b/test/unit/lambda_server_test.rb index d08bc9f..84960b6 100644 --- a/test/unit/lambda_server_test.rb +++ b/test/unit/lambda_server_test.rb @@ -3,6 +3,7 @@ require_relative '../../lib/aws_lambda_ric/lambda_errors' require_relative '../../lib/aws_lambda_ric/lambda_server' require 'net/http' +require 'socket' require 'minitest/autorun' class LambdaServerTest < Minitest::Test @@ -37,10 +38,9 @@ def test_post_invocation_error_with_large_xray_cause headers = {'Lambda-Runtime-Function-Error-Type' => @error.runtime_error_type, 'Lambda-Runtime-Function-XRay-Error-Cause' => large_xray_cause, 'User-Agent' => @mock_user_agent} - post_mock = Minitest::Mock.new - post_mock.expect :call, nil, [@error_uri, @error.to_lambda_response.to_json, headers] + conn_mock = mock_post_connection(@error_uri.path, @error.to_lambda_response.to_json, headers) - Net::HTTP.stub(:post, post_mock) do + Net::HTTP.stub(:new, conn_mock, [@error_uri.host, @error_uri.port]) do @under_test.send_error_response( request_id: @request_id, error_object: @error.to_lambda_response, @@ -49,17 +49,16 @@ def test_post_invocation_error_with_large_xray_cause ) end - assert_mock post_mock + assert_mock conn_mock end def test_post_invocation_error_with_too_large_xray_cause too_large_xray_cause = 'a' * 1024 * 1024 headers = {'Lambda-Runtime-Function-Error-Type' => @error.runtime_error_type, 'User-Agent' => @mock_user_agent} - post_mock = Minitest::Mock.new - post_mock.expect :call, nil, [@error_uri, @error.to_lambda_response.to_json, headers] + conn_mock = mock_post_connection(@error_uri.path, @error.to_lambda_response.to_json, headers) - Net::HTTP.stub(:post, post_mock) do + Net::HTTP.stub(:new, conn_mock, [@error_uri.host, @error_uri.port]) do @under_test.send_error_response( request_id: @request_id, error_object: @error.to_lambda_response, @@ -68,7 +67,47 @@ def test_post_invocation_error_with_too_large_xray_cause ) end - assert_mock post_mock + assert_mock conn_mock + end + + # Regression: with a proxy in the environment, the response must still reach + # the Runtime API directly + def test_send_response_reaches_api_and_not_proxy_when_proxy_is_set + api = RecordingServer.new + proxy = RecordingServer.new + + ['HTTP_PROXY', 'http_proxy'].each do |var| + api.reset + proxy.reset + env_stub(var, "http://#{proxy.address}") do + client = RapidClient.new(api.address, @mock_user_agent) + client.send_response(request_id: @request_id, response_object: 'response') + + assert_equal 1, api.hits, 'response should reach the Runtime API' + assert_equal 0, proxy.hits, "response must not be routed through #{var}" + end + end + ensure + api&.close + proxy&.close + end + + def mock_post_connection(path, body, headers) + conn_mock = Minitest::Mock.new + conn_mock.expect(:start, true) do |&block| + block.call(conn_mock) + true + end + conn_mock.expect(:post, nil, [path, body, headers]) + conn_mock + end + + def env_stub(name, value) + previous = ENV[name] + ENV[name] = value + yield + ensure + ENV[name] = previous end def mock_next_invocation_response() @@ -129,3 +168,57 @@ def test_next_invocation_with_null_tenant_id_header assert_mock get_mock end end + +# A minimal HTTP server that binds to a non-loopback address and counts the +# requests it receives. Non-loopback matters: Net::HTTP never proxies loopback, +# so a 127.0.0.1 target would bypass the proxy. +class RecordingServer + def initialize + ip = Socket.ip_address_list.find { |a| a.ipv4? && !a.ipv4_loopback? && !a.ipv4_multicast? } + raise 'no non-loopback IPv4 interface available' unless ip + + @server = TCPServer.new(ip.ip_address, 0) + @hits = 0 + @lock = Mutex.new + @thread = Thread.new { accept_loop } + end + + def address + "#{@server.addr[3]}:#{@server.addr[1]}" + end + + def hits + @lock.synchronize { @hits } + end + + def reset + @lock.synchronize { @hits = 0 } + end + + def close + @thread&.kill + @server&.close + end + + private + + def accept_loop + loop do + client = @server.accept + @lock.synchronize { @hits += 1 } + drain_request(client) + client.write("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n") + client.close + end + rescue IOError, Errno::EBADF + # server closed + end + + def drain_request(client) + content_length = 0 + while (line = client.gets) && line != "\r\n" + content_length = line.split(':', 2).last.to_i if line =~ /\AContent-Length:/i + end + client.read(content_length) if content_length.positive? + end +end From d5a0a8d1696911f1bb9280cc5dc4472c53c8863b Mon Sep 17 00:00:00 2001 From: maxday Date: Tue, 22 Sep 2026 08:51:15 +0000 Subject: [PATCH 2/4] test: add dockerized regression test for the proxy-bypass fix Adds a handler that asks the fixed RapidClient#build_client for an HTTP client bound to a non-loopback URI while HTTP_PROXY is set, and returns the resulting proxy? values. Suite asserts that a bare Net::HTTP.new picks up the proxy and RapidClient does not. On unfixed code build_client does not exist, the handler raises NoMethodError and the suite fails. --- test/dockerized/suites/proxy.json | 17 +++++++++++++++++ test/dockerized/tasks/proxy.rb | 28 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 test/dockerized/suites/proxy.json create mode 100644 test/dockerized/tasks/proxy.rb diff --git a/test/dockerized/suites/proxy.json b/test/dockerized/suites/proxy.json new file mode 100644 index 0000000..aaf40a7 --- /dev/null +++ b/test/dockerized/suites/proxy.json @@ -0,0 +1,17 @@ +{ + "tests": [ + { + "name": "test_ric_bypasses_http_proxy", + "handler": "proxy.check_proxy_bypass", + "request": {}, + "assertions": [ + { + "response": { + "baseline_uses_proxy": true, + "ric_bypasses_proxy": true + } + } + ] + } + ] +} diff --git a/test/dockerized/tasks/proxy.rb b/test/dockerized/tasks/proxy.rb new file mode 100644 index 0000000..b7c93cb --- /dev/null +++ b/test/dockerized/tasks/proxy.rb @@ -0,0 +1,28 @@ +require 'aws_lambda_ric/lambda_server' +require 'net/http' +require 'uri' + +def check_proxy_bypass(event:, context:) + original_http_proxy = ENV['HTTP_PROXY'] + original_https_proxy = ENV['HTTPS_PROXY'] + ENV['HTTP_PROXY'] = 'http://proxy.example.invalid:3128' + ENV['HTTPS_PROXY'] = 'http://proxy.example.invalid:3128' + + uri = URI('http://169.254.100.1:9001/2018-06-01/runtime/invocation/next') + + # Baseline: a bare Net::HTTP.new (the pre-fix pattern) DOES pick up + # HTTP_PROXY via :ENV proxy resolution for this non-loopback host. + baseline_client = Net::HTTP.new(uri.host, uri.port) + + # Under test: RapidClient#build_client MUST NOT pick up HTTP_PROXY. + rapid_client = RapidClient.new('169.254.100.1:9001', 'ric-proxy-regression') + under_test = rapid_client.send(:build_client, uri) + + { + baseline_uses_proxy: baseline_client.proxy?, + ric_bypasses_proxy: !under_test.proxy? + } +ensure + ENV['HTTP_PROXY'] = original_http_proxy + ENV['HTTPS_PROXY'] = original_https_proxy +end From 6516ad452c27e3711260d200370cea60c59bb9c8 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Tue, 22 Sep 2026 12:51:15 +0000 Subject: [PATCH 3/4] test: reshape proxy regression to a customer-experience end-to-end test The earlier commit introspected RapidClient#build_client directly, which overlapped with the unit tests already added in PR #68. Per review feedback, replace it with a customer-shaped test: bind RIE Runtime API to the container's own non-loopback hostname (so Ruby's URI.find_proxy does not silently bypass the proxy for loopback), set HTTP_PROXY on the image to an unreachable address, and have the handler simply return "success". With the fix the RIC bypasses HTTP_PROXY and the handler runs; without it the RIC tries the unreachable proxy for next_invocation and every suite in the image fails. --- Dockerfile.test | 16 ++++++++++++++- test/dockerized/entrypoint.sh | 23 +++++++++++++++++++++ test/dockerized/suites/proxy.json | 5 +---- test/dockerized/tasks/proxy.rb | 34 ++++++++----------------------- 4 files changed, 48 insertions(+), 30 deletions(-) create mode 100755 test/dockerized/entrypoint.sh diff --git a/Dockerfile.test b/Dockerfile.test index 87b48de..e963537 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -5,4 +5,18 @@ ADD test/dockerized/tasks /var/task RUN gem uninstall aws_lambda_ric --executables ADD pkg /tmp/pkg RUN gem install /tmp/pkg/aws_lambda_ric-*.gem -RUN rm -rf /tmp/pkg \ No newline at end of file +RUN rm -rf /tmp/pkg + +# Route every HTTP call in the container through an unreachable proxy. The +# whole suite runs with this: after the fix the RIC bypasses it for Runtime +# API traffic and every test still passes; before the fix the RIC would +# try to reach 127.0.0.1:1, connect refused, and every test would fail. +ENV HTTP_PROXY=http://127.0.0.1:1 +ENV http_proxy=http://127.0.0.1:1 + +# Custom entrypoint binds RIE's Runtime API to the container's own hostname +# (a non-loopback address), so Ruby's URI.find_proxy loopback bypass does +# not silently hide the bug. +COPY test/dockerized/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/test/dockerized/entrypoint.sh b/test/dockerized/entrypoint.sh new file mode 100755 index 0000000..28f861d --- /dev/null +++ b/test/dockerized/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# + +set -eu + +if [ "$#" -ne 1 ]; then + echo "entrypoint requires the handler name as first argument" 1>&2 + exit 142 +fi +export _HANDLER="$1" + +# Resolve the container's own hostname to its non-loopback IPv4 (docker +# writes this to /etc/hosts for us on eth0). +RIC_HOST="$(getent hosts "$HOSTNAME" | awk '{print $1; exit}')" +if [ -z "$RIC_HOST" ]; then + echo "entrypoint could not resolve \$HOSTNAME ($HOSTNAME)" 1>&2 + exit 143 +fi + +exec /usr/local/bin/aws-lambda-rie \ + --runtime-api-address "$RIC_HOST:9001" \ + /var/runtime/bootstrap diff --git a/test/dockerized/suites/proxy.json b/test/dockerized/suites/proxy.json index aaf40a7..9abb3f0 100644 --- a/test/dockerized/suites/proxy.json +++ b/test/dockerized/suites/proxy.json @@ -6,10 +6,7 @@ "request": {}, "assertions": [ { - "response": { - "baseline_uses_proxy": true, - "ric_bypasses_proxy": true - } + "response": "success" } ] } diff --git a/test/dockerized/tasks/proxy.rb b/test/dockerized/tasks/proxy.rb index b7c93cb..18f2d1c 100644 --- a/test/dockerized/tasks/proxy.rb +++ b/test/dockerized/tasks/proxy.rb @@ -1,28 +1,12 @@ -require 'aws_lambda_ric/lambda_server' -require 'net/http' -require 'uri' +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# The image (see Dockerfile.test) runs with HTTP_PROXY pointed at an +# unreachable address and RIE's Runtime API bound to a non-loopback +# hostname. If the proxy-bypass fix (PR #68) is in place the RIC bypasses +# HTTP_PROXY for Runtime API calls and this handler runs to completion, +# returning "success". Without the fix the RIC would try to reach the +# unreachable proxy for next_invocation and this handler would never run. def check_proxy_bypass(event:, context:) - original_http_proxy = ENV['HTTP_PROXY'] - original_https_proxy = ENV['HTTPS_PROXY'] - ENV['HTTP_PROXY'] = 'http://proxy.example.invalid:3128' - ENV['HTTPS_PROXY'] = 'http://proxy.example.invalid:3128' - - uri = URI('http://169.254.100.1:9001/2018-06-01/runtime/invocation/next') - - # Baseline: a bare Net::HTTP.new (the pre-fix pattern) DOES pick up - # HTTP_PROXY via :ENV proxy resolution for this non-loopback host. - baseline_client = Net::HTTP.new(uri.host, uri.port) - - # Under test: RapidClient#build_client MUST NOT pick up HTTP_PROXY. - rapid_client = RapidClient.new('169.254.100.1:9001', 'ric-proxy-regression') - under_test = rapid_client.send(:build_client, uri) - - { - baseline_uses_proxy: baseline_client.proxy?, - ric_bypasses_proxy: !under_test.proxy? - } -ensure - ENV['HTTP_PROXY'] = original_http_proxy - ENV['HTTPS_PROXY'] = original_https_proxy + 'success' end From eb2551c0e73187789ac8da941666ca70884711b5 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Tue, 22 Sep 2026 13:54:08 +0000 Subject: [PATCH 4/4] test: scope proxy setup to a dedicated image and suite Reviewer flagged that setting HTTP_PROXY globally in Dockerfile.test made every suite exercise the fix, so proxy.json no longer proved anything the other suites did not, and a regression would break the whole matrix instead of the one intended test. Move the proxy setup into a second image (Dockerfile.test.proxy) that extends local/test with HTTP_PROXY and the custom entrypoint, and move the proxy suite under test/dockerized/suites/proxy/ so the default suites/*.json glob no longer picks it up. Workflow now builds both images and runs each glob against its own image. --- .github/workflows/dockerized-test.yml | 10 ++++++++++ Dockerfile.test | 14 -------------- Dockerfile.test.proxy | 15 +++++++++++++++ test/dockerized/suites/{ => proxy}/proxy.json | 0 4 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 Dockerfile.test.proxy rename test/dockerized/suites/{ => proxy}/proxy.json (100%) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 4115d12..f5c5baa 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -39,3 +39,13 @@ jobs: suiteFileArray: '["./test/dockerized/suites/*.json"]' dockerImageName: 'local/test' taskFolder: './test/dockerized/tasks' + + - name: Build the proxy image + run: docker build . -t local/test-proxy -f Dockerfile.test.proxy + + - name: Run proxy tests + uses: aws/containerized-test-runner-for-aws-lambda@511d270614f2c6b1613848db6dcf920a591c3c89 # main + with: + suiteFileArray: '["./test/dockerized/suites/proxy/*.json"]' + dockerImageName: 'local/test-proxy' + taskFolder: './test/dockerized/tasks' diff --git a/Dockerfile.test b/Dockerfile.test index e963537..2b1cb16 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -6,17 +6,3 @@ RUN gem uninstall aws_lambda_ric --executables ADD pkg /tmp/pkg RUN gem install /tmp/pkg/aws_lambda_ric-*.gem RUN rm -rf /tmp/pkg - -# Route every HTTP call in the container through an unreachable proxy. The -# whole suite runs with this: after the fix the RIC bypasses it for Runtime -# API traffic and every test still passes; before the fix the RIC would -# try to reach 127.0.0.1:1, connect refused, and every test would fail. -ENV HTTP_PROXY=http://127.0.0.1:1 -ENV http_proxy=http://127.0.0.1:1 - -# Custom entrypoint binds RIE's Runtime API to the container's own hostname -# (a non-loopback address), so Ruby's URI.find_proxy loopback bypass does -# not silently hide the bug. -COPY test/dockerized/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] diff --git a/Dockerfile.test.proxy b/Dockerfile.test.proxy new file mode 100644 index 0000000..e23ec44 --- /dev/null +++ b/Dockerfile.test.proxy @@ -0,0 +1,15 @@ +# Variant of Dockerfile.test used only for the proxy-regression suite. +# Extends the standard test image with an unreachable HTTP_PROXY and an +# entrypoint that binds RIE's Runtime API to the container's own +# non-loopback hostname. Kept separate from Dockerfile.test so the other +# suites keep running against a plain RIE-on-loopback setup. +# +# Requires local/test to be built first (see .github/workflows/dockerized-test.yml). +FROM local/test + +ENV HTTP_PROXY=http://127.0.0.1:1 +ENV http_proxy=http://127.0.0.1:1 + +COPY test/dockerized/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/test/dockerized/suites/proxy.json b/test/dockerized/suites/proxy/proxy.json similarity index 100% rename from test/dockerized/suites/proxy.json rename to test/dockerized/suites/proxy/proxy.json