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 87b48de..2b1cb16 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -5,4 +5,4 @@ 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 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/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/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/proxy.json b/test/dockerized/suites/proxy/proxy.json new file mode 100644 index 0000000..9abb3f0 --- /dev/null +++ b/test/dockerized/suites/proxy/proxy.json @@ -0,0 +1,14 @@ +{ + "tests": [ + { + "name": "test_ric_bypasses_http_proxy", + "handler": "proxy.check_proxy_bypass", + "request": {}, + "assertions": [ + { + "response": "success" + } + ] + } + ] +} diff --git a/test/dockerized/tasks/proxy.rb b/test/dockerized/tasks/proxy.rb new file mode 100644 index 0000000..18f2d1c --- /dev/null +++ b/test/dockerized/tasks/proxy.rb @@ -0,0 +1,12 @@ +# 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:) + 'success' +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