diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3aaa70..d1576d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +* Fix API Gateway v2 (HTTP API) collapsing repeated query parameters such as `?id=1&id=2` into a single comma-joined value; `QUERY_STRING` is now built from the event's `rawQueryString`, which also fixes ASGI apps and Lambda Function URLs (#1472) * Change default of `num_retained_versions` from `null` (keep all) to `5` (#1453) - Lambda code storage and SnapStart snapshot-cache cost now have a sane default upper bound. - On the first `zappa update` after upgrade, published versions older than the newest 5 are deleted; versions referenced by an alias (e.g. ALB) and `$LATEST` are unaffected, but versions referenced by other aliases can still raise `ResourceConflictException` (see #960). diff --git a/tests/test_asgi.py b/tests/test_asgi.py index e4b2297d..72e576a6 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -418,6 +418,38 @@ def test_asgi_v1_with_query_string(self): self.assertIn("foo=bar", response["body"]) self.assertIn("baz=qux", response["body"]) + def test_asgi_v2_with_repeated_query_string(self): + """ + Repeated query parameters must survive the payload format 2.0 + conversion into the ASGI scope, not be collapsed into "id=18,19,20". + https://github.com/zappa/Zappa/issues/1472 + """ + lh = LambdaHandler("tests.test_asgi_settings") + + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/return/request/url", + "rawQueryString": "id=18&id=19&id=20", + # This is the lossy value API Gateway v2 actually sends alongside it. + "queryStringParameters": {"id": "18,19,20"}, + "headers": { + "host": "example.com", + }, + "requestContext": { + "http": { + "method": "GET", + "path": "/return/request/url", + }, + }, + "isBase64Encoded": False, + "body": "", + } + response = lh.handler(event, None) + + self.assertEqual(response["statusCode"], 200) + self.assertIn("?id=18&id=19&id=20", response["body"]) + def test_asgi_404(self): """Ensure ASGI app returns 404 for unknown routes.""" lh = LambdaHandler("tests.test_asgi_settings") diff --git a/tests/test_core.py b/tests/test_core.py index 2d2dca8d..a4bf252c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -18,6 +18,7 @@ from io import BytesIO from pathlib import Path from subprocess import check_output +from urllib.parse import parse_qs import botocore import botocore.stub @@ -4600,6 +4601,89 @@ def test_wsgi_query_string_with_encodechars(self): expected = "query=Jane%26John&otherquery=B&test=hello%2Bm.te%26how%26are%26you" self.assertEqual(request["QUERY_STRING"], expected) + @staticmethod + def _v2_event(**overrides): + """Minimal API Gateway payload format 2.0 event, for query string tests.""" + event = { + "version": "2.0", + "routeKey": "ANY /{proxy+}", + "rawPath": "/path/path1", + "rawQueryString": "", + "headers": {"host": "example.com"}, + "requestContext": { + "http": { + "method": "GET", + "path": "/path/path1", + "protocol": "HTTP/1.1", + "sourceIp": "50.191.225.98", + }, + "stage": "$default", + }, + "isBase64Encoded": False, + } + event.update(overrides) + return event + + def test_wsgi_v2_repeated_query_params_preserved(self): + """ + API Gateway v2 flattens ?id=18&id=19&id=20 into + queryStringParameters {"id": "18,19,20"}. rawQueryString keeps the + original repetition and must be what reaches the application. + https://github.com/zappa/Zappa/issues/1472 + """ + event = self._v2_event( + rawQueryString="id=18&id=19&id=20", + queryStringParameters={"id": "18,19,20"}, + ) + request = create_wsgi_request(event) + self.assertEqual(request["QUERY_STRING"], "id=18&id=19&id=20") + self.assertEqual(parse_qs(request["QUERY_STRING"]), {"id": ["18", "19", "20"]}) + + def test_wsgi_v2_literal_comma_in_value_preserved(self): + """ + A comma inside a single value must not be mistaken for a separator. + queryStringParameters cannot express the difference; rawQueryString can. + """ + event = self._v2_event( + rawQueryString="tags=a%2Cb&tags=c", + queryStringParameters={"tags": "a,b,c"}, + ) + request = create_wsgi_request(event) + self.assertEqual(parse_qs(request["QUERY_STRING"]), {"tags": ["a,b", "c"]}) + + def test_wsgi_v2_query_string_not_double_encoded(self): + """rawQueryString arrives percent-encoded and must be passed through as-is.""" + event = self._v2_event( + rawQueryString="test=M%26M&query=C%23D&utf=caf%C3%A9", + queryStringParameters={"test": "M&M", "query": "C#D", "utf": "café"}, + ) + request = create_wsgi_request(event) + self.assertEqual(request["QUERY_STRING"], "test=M%26M&query=C%23D&utf=caf%C3%A9") + self.assertEqual( + parse_qs(request["QUERY_STRING"]), + {"test": ["M&M"], "query": ["C#D"], "utf": ["café"]}, + ) + + def test_wsgi_v2_empty_query_string(self): + """An empty rawQueryString must not fall back to queryStringParameters.""" + request = create_wsgi_request(self._v2_event()) + self.assertEqual(request["QUERY_STRING"], "") + + def test_wsgi_v2_without_raw_query_string_falls_back(self): + """ + Invokers other than API Gateway may send a v2-shaped event with no + rawQueryString. Fall back to queryStringParameters, honouring lists. + """ + event = self._v2_event(queryStringParameters={"a": "1", "b": "C#D"}) + del event["rawQueryString"] + request = create_wsgi_request(event) + self.assertEqual(request["QUERY_STRING"], "a=1&b=C%23D") + + event = self._v2_event(queryStringParameters={"a": ["1", "2"]}) + del event["rawQueryString"] + request = create_wsgi_request(event) + self.assertEqual(request["QUERY_STRING"], "a=1&a=2") + @mock.patch("subprocess.Popen") def test_create_handler_venv_win32_none_stderror_result(self, popen_mock): class PopenMock: diff --git a/tests/test_handler.py b/tests/test_handler.py index 668f81fe..f6cff709 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -176,6 +176,42 @@ def test_wsgi_script_name_with_multi_value_querystring(self): "https://example.com/return/request/url?multi=value&multi=qs", ) + def test_wsgi_script_name_on_v2_event_with_multi_value_querystring(self): + """ + API Gateway payload format 2.0 has no multiValueQueryStringParameters + and collapses repeats into a comma-joined queryStringParameters value. + The application must still see the repeated parameters. + https://github.com/zappa/Zappa/issues/1472 + """ + lh = LambdaHandler("tests.test_wsgi_script_name_settings") + + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/return/request/url", + "rawQueryString": "multi=value&multi=qs", + # This is the lossy value API Gateway v2 actually sends alongside it. + "queryStringParameters": {"multi": "value,qs"}, + "headers": { + "host": "example.com", + }, + "requestContext": { + "http": { + "method": "GET", + "path": "/return/request/url", + }, + }, + "isBase64Encoded": False, + "body": "", + } + response = lh.handler(event, None) + + self.assertEqual(response["statusCode"], 200) + self.assertEqual( + response["body"], + "https://example.com/return/request/url?multi=value&multi=qs", + ) + def test_wsgi_script_name_on_test_request(self): """ Ensure that requests sent by the "Send test request" button behaves diff --git a/zappa/wsgi.py b/zappa/wsgi.py index b318d770..874390e6 100644 --- a/zappa/wsgi.py +++ b/zappa/wsgi.py @@ -164,8 +164,13 @@ def process_lambda_payload_v2(event_info): if event_info.get("cookies"): headers["Cookie"] = "; ".join(event_info["cookies"]) path = unquote(event_info["rawPath"]) - query = event_info.get("queryStringParameters", {}) - query_string = urlencode(query) if query else "" + # queryStringParameters comma-joins repeated params; rawQueryString does not. + # https://github.com/zappa/Zappa/issues/1472 + if "rawQueryString" in event_info: + query_string = event_info["rawQueryString"] or "" + else: + query = event_info.get("queryStringParameters", {}) + query_string = urlencode(query, doseq=True) if query else "" # Systems calling the Lambda (other than API Gateway) may not provide the field requestContext # Extract remote_user, authorizer if Authorizer is enabled remote_user = None