-
Notifications
You must be signed in to change notification settings - Fork 357
Parse Lambda AppSec request bodies according to their content type #12363
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
Open
claponcet
wants to merge
17
commits into
master
Choose a base branch
from
clara.poncet/lambda-appsec-body-parsing
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
4acc832
content type dispatch
claponcet 291b70c
Report multipart file part filenames to the WAF
claponcet 3ea96fb
Keep multipart parts that declare no content type as raw strings
claponcet ebad522
Harden Lambda AppSec body parsing allowances and multipart header reads
claponcet 8521f8b
Require a multipart delimiter to end its line
claponcet 0ed399c
Trim redundant Lambda body parsing tests and comments
claponcet 2a005be
Collapse the JSON content-type gate into one method and drop unchecke…
claponcet d9c5296
Keep a malformed multipart body as a raw string instead of reporting …
claponcet 58ba1ec
Drop redundant Lambda body parsing test cases and merge overlapping ones
claponcet 693dfe0
Require a close delimiter to end its line and void a body truncated i…
claponcet 6297d2c
Read a multipart delimiter at a part's content start as content
claponcet 3c01469
Report form fields with an empty name instead of dropping them
claponcet fad3363
Fix a typo in the unsupported-trigger comment
claponcet cbeaf2e
Decode urlencoded form bodies with the charset they declare
claponcet b0e1025
Merge multiValueHeaders into API Gateway v1 request headers
claponcet 7eb46dc
Join repeated Cookie headers with "; " instead of ", "
claponcet 9216874
Read the body charset with the multipart parameter reader
claponcet 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
315 changes: 315 additions & 0 deletions
315
dd-trace-core/src/main/java/datadog/trace/lambda/ContentTypeBodyParser.java
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,315 @@ | ||
| package datadog.trace.lambda; | ||
|
|
||
| import datadog.trace.api.appsec.MediaType; | ||
| import datadog.trace.lambda.MultipartSplitter.Part; | ||
| import java.io.UnsupportedEncodingException; | ||
| import java.net.URLDecoder; | ||
| import java.nio.charset.Charset; | ||
| import java.util.ArrayList; | ||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.StringTokenizer; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| /** | ||
| * Turns a Lambda request body into the shape the AppSec WAF expects. The declared {@code | ||
| * Content-Type} decides how the body is structured; a best-effort JSON parse handles the JSON-ish | ||
| * types and a top-level body that declares no type at all. | ||
| * | ||
| * <p>A body is never dropped: any type we cannot structure — and any parse failure — degrades to | ||
| * the raw {@link String}, which the WAF can still match string rules against. | ||
| */ | ||
| final class ContentTypeBodyParser { | ||
|
|
||
| private static final Logger log = LoggerFactory.getLogger(ContentTypeBodyParser.class); | ||
|
|
||
| // These bound the work done in this parser only: exceeding any of them degrades the body to a raw | ||
| // string rather than dropping content. | ||
| static final int MAX_BYTES = 1024 * 1024; | ||
| static final int MAX_PARTS = 256; | ||
| static final int MAX_DEPTH = 20; | ||
|
|
||
| /** What a form body's percent-escapes mean when it declares no charset of its own. */ | ||
| private static final String DEFAULT_CHARSET = "UTF-8"; | ||
|
|
||
| private ContentTypeBodyParser() {} | ||
|
|
||
| /** | ||
| * State shared across a whole parse: the byte and part allowances, and the filenames collected | ||
| * along the way. A multipart part may itself hold a multipart body, so a per-call allowance would | ||
| * be re-satisfied at every nesting level. | ||
| */ | ||
| static final class ParseContext { | ||
| private int bytes; | ||
| private int parts = MAX_PARTS; | ||
|
|
||
| ParseContext() { | ||
| this(MAX_BYTES); | ||
| } | ||
|
|
||
| /** | ||
| * @param byteAllowance the total number of characters this parse may read, nesting included | ||
| */ | ||
| ParseContext(final int byteAllowance) { | ||
| this.bytes = byteAllowance; | ||
| } | ||
|
|
||
| /** Allocated only once a file part is seen, which most bodies never do. */ | ||
| private List<String> filenames; | ||
|
|
||
| int remainingParts() { | ||
| return parts; | ||
| } | ||
|
|
||
| void consumeParts(final int count) { | ||
| parts -= count; | ||
| } | ||
|
|
||
| /** | ||
| * @return {@code false} when the parse can no longer afford to read {@code count} characters | ||
| */ | ||
| boolean takeBytes(final int count) { | ||
| if (bytes < count) { | ||
| return false; | ||
| } | ||
| bytes -= count; | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * @return {@code false} once the part allowance is spent. A multipart part and an urlencoded | ||
| * parameter both draw from it. | ||
| */ | ||
| boolean takePart() { | ||
| if (parts == 0) { | ||
| return false; | ||
| } | ||
| parts--; | ||
| return true; | ||
| } | ||
|
|
||
| void addFilename(final String filename) { | ||
| if (filenames == null) { | ||
| filenames = new ArrayList<>(2); | ||
| } | ||
| filenames.add(filename); | ||
| } | ||
|
|
||
| /** | ||
| * @return the filenames of the multipart file parts found, in body order, empty when there were | ||
| * none | ||
| */ | ||
| List<String> filenames() { | ||
| return filenames == null ? Collections.emptyList() : filenames; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Parses a decoded request body according to its {@code Content-Type}. | ||
| * | ||
| * @param context also collects the filenames of any multipart file parts found, which the caller | ||
| * reports separately from the body | ||
| */ | ||
| static Object parseBody(final String body, final String contentType, final ParseContext context) { | ||
| return dispatch(body, contentType, 0, context); | ||
| } | ||
|
|
||
| static Object dispatch( | ||
| final String body, final String contentType, final int depth, final ParseContext context) { | ||
| if (body == null) { | ||
| return null; | ||
| } | ||
| if (depth >= MAX_DEPTH) { | ||
| log.debug("Body nesting depth {} reached, keeping raw string", depth); | ||
| return body; | ||
| } | ||
| if (!context.takeBytes(body.length())) { | ||
| log.debug( | ||
| "Byte allowance cannot cover a body of {} chars, keeping raw string", body.length()); | ||
| return body; | ||
| } | ||
| final MediaType mediaType = MediaType.parse(contentType); | ||
| if (isJsonOrUntyped(mediaType)) { | ||
| final Object parsed = LambdaEventParser.parseBodyAsJson(body); | ||
| return parsed != null ? parsed : body; | ||
| } | ||
| if ("application".equals(mediaType.getType()) | ||
| && "x-www-form-urlencoded".equals(mediaType.getSubtype())) { | ||
| final Object parsed = parseUrlEncoded(body, charsetName(contentType), context); | ||
| return parsed != null ? parsed : body; | ||
| } | ||
| if ("multipart".equals(mediaType.getType())) { | ||
| final Object parsed = parseMultipart(body, contentType, depth, context); | ||
| return parsed != null ? parsed : body; | ||
| } | ||
| // text/* and everything else stay raw strings. In particular a text/plain body of "12345" must | ||
| // reach the WAF as a String, not as the Double a JSON parse would produce. | ||
| return body; | ||
| } | ||
|
|
||
| static boolean isJsonOrUntyped(final MediaType mediaType) { | ||
| final String subtype = mediaType.getSubtype(); | ||
| return mediaType.getType() == null | ||
| || (subtype != null && (subtype.contains("json") || subtype.contains("javascript"))); | ||
| } | ||
|
|
||
| /** | ||
| * Parses an {@code application/x-www-form-urlencoded} body into a multimap, matching the shape | ||
| * produced for query parameters. | ||
| * | ||
| * @return the parsed parameters, or {@code null} if nothing usable was found or the body exhausts | ||
| * the part allowance | ||
| */ | ||
| private static Map<String, List<String>> parseUrlEncoded( | ||
| final String body, final String charset, final ParseContext context) { | ||
| if (body.isEmpty()) { | ||
| return null; | ||
| } | ||
| final Map<String, List<String>> parameters = new LinkedHashMap<>(); | ||
| final StringTokenizer tokenizer = new StringTokenizer(body, "&"); | ||
| while (tokenizer.hasMoreTokens()) { | ||
| if (!context.takePart()) { | ||
| log.debug("Part allowance exhausted, keeping urlencoded body as a raw string"); | ||
| return null; | ||
| } | ||
| final String pair = tokenizer.nextToken(); | ||
| final int equals = pair.indexOf('='); | ||
| // An empty name is kept rather than dropped: the handler still decodes the parameter, so | ||
| // dropping it would hide its value from the WAF. The Netty body collector keeps it too. | ||
| final String name = decode(equals == -1 ? pair : pair.substring(0, equals), charset); | ||
| parameters | ||
| .computeIfAbsent(name, k -> new ArrayList<>(1)) | ||
| .add(equals == -1 ? "" : decode(pair.substring(equals + 1), charset)); | ||
| } | ||
| if (parameters.isEmpty()) { | ||
| return null; | ||
| } | ||
| log.debug("Body parsed as {} urlencoded parameters", parameters.size()); | ||
| return parameters; | ||
| } | ||
|
|
||
| /** | ||
| * Parses a {@code multipart/*} body into its form fields. | ||
| * | ||
| * @return the fields found, or {@code null} if the body has no usable boundary, it holds more | ||
| * parts than the allowance, or it yields no field | ||
| */ | ||
| private static Object parseMultipart( | ||
| final String body, final String contentType, final int depth, final ParseContext context) { | ||
| final String boundary = MultipartSplitter.extractBoundary(contentType); | ||
| if (boundary == null) { | ||
| log.debug("Multipart body without a usable boundary, keeping raw string"); | ||
| return null; | ||
| } | ||
| // One over the allowance, so that a body holding more parts than may be read is distinguishable | ||
| // from one holding exactly the allowance | ||
| final int allowance = context.remainingParts(); | ||
| final List<Part> parts = MultipartSplitter.split(body, boundary, allowance + 1); | ||
| if (parts.size() > allowance) { | ||
| log.debug("Part allowance exhausted, keeping multipart body as a raw string"); | ||
| return null; | ||
| } | ||
| context.consumeParts(parts.size()); | ||
|
|
||
| final Map<String, Object> fields = new LinkedHashMap<>(); | ||
| final Map<String, List<Object>> promoted = new HashMap<>(); | ||
| for (final Part part : parts) { | ||
| final String disposition = part.contentDisposition; | ||
| if (disposition == null) { | ||
| continue; | ||
| } | ||
| final String filename = MultipartSplitter.parameter(disposition, "filename"); | ||
| if (filename != null) { | ||
| if (!filename.isEmpty()) { | ||
| context.addFilename(filename); | ||
| } | ||
| continue; | ||
| } | ||
| // A part with no name parameter at all is not a form field, but one named "" is: the handler | ||
| // decodes it, so it is reported under the empty key rather than dropped, as Netty does. | ||
| final String name = MultipartSplitter.parameter(disposition, "name"); | ||
| if (name == null) { | ||
| continue; | ||
| } | ||
| final String partContentType = part.contentType; | ||
| final String content = body.substring(part.contentStart, part.contentEnd); | ||
| // A part that declares no type is kept as a raw string | ||
| final Object value = | ||
| partContentType == null || partContentType.isEmpty() | ||
| ? content | ||
| : dispatch(content, partContentType, depth + 1, context); | ||
| addField(fields, promoted, name, value); | ||
| } | ||
| return fields.isEmpty() ? null : fields; | ||
| } | ||
|
|
||
| /** | ||
| * Accumulates a field as a scalar on first sight and promotes it to a list on repeat. | ||
| * Deliberately a different shape from urlencoded's always-a-list, matching the peer tracers. | ||
| * | ||
| * @param promoted the list each promoted field was given, by name, mutated as fields are | ||
| * promoted. Tracked rather than inferred from the stored value's type: a part whose body | ||
| * parsed as a JSON array is itself a List, and appending to it would flatten the two apart. | ||
| */ | ||
| private static void addField( | ||
| final Map<String, Object> fields, | ||
| final Map<String, List<Object>> promoted, | ||
| final String name, | ||
| final Object value) { | ||
| final List<Object> values = promoted.get(name); | ||
| if (values != null) { | ||
| values.add(value); | ||
| return; | ||
| } | ||
| // A part value is never null, so an absent key is exactly a null lookup | ||
| final Object existing = fields.get(name); | ||
| if (existing == null) { | ||
| fields.put(name, value); | ||
| } else { | ||
| final List<Object> promotion = new ArrayList<>(2); | ||
| promotion.add(existing); | ||
| promotion.add(value); | ||
| fields.put(name, promotion); | ||
| promoted.put(name, promotion); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the charset a form body declares, which decides what its percent-escapes mean: {@code | ||
| * %E9} is one character under ISO-8859-1 and an invalid sequence under UTF-8. Reading it the way | ||
| * the handler does keeps the WAF matching on the same value the application consumes. | ||
| * | ||
| * @return the declared charset, or UTF-8 when none is declared or it is not one this JVM has | ||
| */ | ||
| private static String charsetName(final String contentType) { | ||
| final String declared = MultipartSplitter.parameter(contentType, "charset"); | ||
| if (declared == null || declared.isEmpty()) { | ||
| return DEFAULT_CHARSET; | ||
| } | ||
| try { | ||
| if (Charset.isSupported(declared)) { | ||
| return declared; | ||
| } | ||
| } catch (final IllegalArgumentException e) { | ||
| // Not a charset name at all: a body may declare anything | ||
| } | ||
| log.debug("Unsupported charset {} declared, decoding the body as UTF-8", declared); | ||
| return DEFAULT_CHARSET; | ||
| } | ||
|
|
||
| /** Percent-decodes a single token, keeping it undecoded rather than dropping it on failure. */ | ||
| private static String decode(final String value, final String charset) { | ||
| if (value.isEmpty()) { | ||
| return value; | ||
| } | ||
| try { | ||
| return URLDecoder.decode(value, charset); | ||
| } catch (final UnsupportedEncodingException | IllegalArgumentException e) { | ||
| return value; | ||
| } | ||
| } | ||
| } | ||
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
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.