-
Notifications
You must be signed in to change notification settings - Fork 440
feat(asm): add handlers to support the AWS Lambda framework #13638
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
florentinl
merged 5 commits into
main
from
florentin.labelle/APPSEC-57889/waf-for-aws-lambda
Jun 17, 2025
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3419a04
fix(asm): process SERVERLESS spans when calling the waf inside aws la…
florentinl b1eb42f
feat(asm): add handlers for processing aws lambda http requests
florentinl 28138a0
Merge branch 'main' into florentin.labelle/APPSEC-57889/waf-for-aws-l…
florentinl a1905bb
fix optionals
florentinl 25b01e6
remove redundant test in _waf_action
florentinl File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import base64 | ||
from http.cookies import SimpleCookie | ||
import json | ||
from typing import Any | ||
from typing import Dict | ||
from typing import Optional | ||
from typing import Union | ||
from urllib.parse import parse_qs | ||
|
||
import xmltodict | ||
|
||
from ddtrace.internal.utils import http as http_utils | ||
|
||
|
||
def normalize_headers( | ||
request_headers: Dict[str, str], | ||
) -> Dict[str, Optional[str]]: | ||
"""Normalize headers according to the WAF expectations. | ||
|
||
The WAF expects headers to be lowercased and empty values to be None. | ||
""" | ||
headers: Dict[str, Optional[str]] = {} | ||
for key, value in request_headers.items(): | ||
normalized_key = http_utils.normalize_header_name(key) | ||
if value: | ||
headers[normalized_key] = str(value).strip() | ||
else: | ||
headers[normalized_key] = None | ||
return headers | ||
|
||
|
||
def parse_http_body( | ||
normalized_headers: Dict[str, Optional[str]], | ||
body: Optional[str], | ||
is_body_base64: bool, | ||
) -> Union[str, Dict[str, Any], None]: | ||
"""Parse a request body based on the content-type header.""" | ||
if body is None: | ||
return None | ||
if is_body_base64: | ||
try: | ||
body = base64.b64decode(body).decode() | ||
except (ValueError, TypeError): | ||
return None | ||
|
||
try: | ||
content_type = normalized_headers.get("content-type") | ||
if not content_type: | ||
return None | ||
|
||
if content_type in ("application/json", "application/vnd.api+json", "text/json"): | ||
return json.loads(body) | ||
elif content_type in ("application/x-url-encoded", "application/x-www-form-urlencoded"): | ||
return parse_qs(body) | ||
elif content_type in ("application/xml", "text/xml"): | ||
return xmltodict.parse(body) | ||
elif content_type.startswith("multipart/form-data"): | ||
return http_utils.parse_form_multipart(body, normalized_headers) | ||
elif content_type == "text/plain": | ||
return body | ||
else: | ||
return None | ||
|
||
except Exception: | ||
return None | ||
|
||
|
||
def extract_cookies_from_headers( | ||
normalized_headers: Dict[str, Optional[str]], | ||
) -> Optional[Dict[str, str]]: | ||
"""Extract cookies from the WAF headers.""" | ||
cookie_names = {"cookie", "set-cookie"} | ||
for name in cookie_names: | ||
if name in normalized_headers: | ||
cookie = SimpleCookie() | ||
header = normalized_headers[name] | ||
del normalized_headers[name] | ||
if header: | ||
cookie.load(header) | ||
return {k: v.value for k, v in cookie.items()} | ||
return None |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import pytest | ||
|
||
from ddtrace.appsec import _http_utils | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"input_headers, expected", | ||
[ | ||
({"Host": "Example.COM"}, {"host": "Example.COM"}), | ||
( | ||
{"X-Custom-None": "", "Content-Type": "application/json", "X-Custom-Spacing ": " trim spaces "}, | ||
{"x-custom-none": None, "content-type": "application/json", "x-custom-spacing": "trim spaces"}, | ||
), | ||
], | ||
) | ||
def test_normalize_headers(input_headers, expected): | ||
result = _http_utils.normalize_headers(input_headers) | ||
assert result == expected | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"headers, body, is_body_base64, expected_output", | ||
[ | ||
# Body is None | ||
({}, None, False, None), | ||
# Base64 encoded body - text/plain | ||
( | ||
{"content-type": "text/plain"}, | ||
"dGV4dCBib2R5", | ||
True, | ||
"text body", | ||
), | ||
# Base64 encoded body - application/json | ||
( | ||
{"content-type": "application/json"}, | ||
"eyJrZXkiOiAidmFsdWUifQ==", | ||
True, | ||
{"key": "value"}, | ||
), | ||
# Base64 decoding failure - text/plain | ||
( | ||
{"content-type": "text/plain"}, | ||
"invalid_base64_string", | ||
True, | ||
None, | ||
), | ||
# JSON content types | ||
({"content-type": "application/json"}, '{"key": "value"}', False, {"key": "value"}), | ||
({"content-type": "application/vnd.api+json"}, '{"key": "value"}', False, {"key": "value"}), | ||
({"content-type": "text/json"}, '{"key": "value"}', False, {"key": "value"}), | ||
# Form urlencoded | ||
( | ||
{"content-type": "application/x-www-form-urlencoded"}, | ||
"key=value&key2=value2", | ||
False, | ||
{"key": ["value"], "key2": ["value2"]}, | ||
), | ||
# XML content types | ||
({"content-type": "application/xml"}, "<root><key>value</key></root>", False, {"root": {"key": "value"}}), | ||
({"content-type": "text/xml"}, "<root><key>value</key></root>", False, {"root": {"key": "value"}}), | ||
# Text plain | ||
({"content-type": "text/plain"}, "simple text body", False, "simple text body"), | ||
# Unsupported content type | ||
({"content-type": "application/octet-stream"}, "binary data", False, None), | ||
# No content type provided | ||
({}, "some body", False, None), | ||
# Invalid JSON | ||
({"content-type": "application/json"}, "not a valid json string", False, None), | ||
# Invalid XML | ||
({"content-type": "application/xml"}, "<root><key>value</missing_key></root>", False, None), | ||
# Multipart form data | ||
( | ||
{"content-type": "multipart/form-data; boundary=boundary"}, | ||
( | ||
"--boundary\r\n" | ||
'Content-Disposition: form-data; name="formPart"\r\n' | ||
"content-type: application/x-www-form-urlencoded\r\n" | ||
"\r\n" | ||
"key=value\r\n" | ||
"--boundary--" | ||
), | ||
False, | ||
{"formPart": {"key": ["value"]}}, # Mocked return value for parse_form_multipart | ||
), | ||
# Invalid base64 encoded body (decoding fails) | ||
( | ||
{"content-type": "application/xml"}, | ||
"invalid_base64_and_invalid_xml", | ||
True, | ||
None, | ||
), | ||
], | ||
) | ||
def test_parse_http_body(headers, body, is_body_base64, expected_output, mocker): | ||
result = _http_utils.parse_http_body(headers, body, is_body_base64) | ||
assert result == expected_output | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"input_headers, expected", | ||
[ | ||
( | ||
{"cookie": "sessionid=abc123; csrftoken=xyz789"}, | ||
{"sessionid": "abc123", "csrftoken": "xyz789"}, | ||
), | ||
( | ||
{"set-cookie": "sessionid=abc123; Path=/; HttpOnly"}, | ||
{"sessionid": "abc123"}, | ||
), | ||
({"cookie": ""}, {}), | ||
({"cookie": None}, {}), | ||
({"set-cookie": None}, {}), | ||
], | ||
) | ||
# Tests for extract_cookies_from_headers | ||
def test_extract_cookies_from_headers(input_headers, expected): | ||
result = _http_utils.extract_cookies_from_headers(input_headers) | ||
assert result == expected |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.