diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c0d9c20c1d8..342c0c4058a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -49,7 +49,7 @@ /dd-java-agent/instrumentation/azure-functions/ @DataDog/apm-serverless /dd-java-agent/instrumentation/azure-functions-1.2.2/ @DataDog/apm-serverless /dd-trace-core/src/main/java/datadog/trace/lambda/ @DataDog/apm-serverless -/dd-trace-core/src/test/groovy/datadog/trace/lambda/ @DataDog/apm-serverless +/dd-trace-core/src/test/*/datadog/trace/lambda/ @DataDog/apm-serverless /utils/container-utils/ @DataDog/apm-serverless **/InferredProxy*.java @DataDog/apm-serverless **/InferredProxy*.groovy @DataDog/apm-serverless diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-lambda-handler-1.2/src/test/java/LambdaHandlerInstrumentationTest.java b/dd-java-agent/instrumentation/aws-java/aws-java-lambda-handler-1.2/src/test/java/LambdaHandlerInstrumentationTest.java index 6bb27aef0d5..a59aafc2710 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-lambda-handler-1.2/src/test/java/LambdaHandlerInstrumentationTest.java +++ b/dd-java-agent/instrumentation/aws-java/aws-java-lambda-handler-1.2/src/test/java/LambdaHandlerInstrumentationTest.java @@ -17,6 +17,7 @@ import com.amazonaws.services.lambda.runtime.LambdaLogger; import datadog.trace.agent.test.AbstractInstrumentationTest; import datadog.trace.api.DDSpanTypes; +import datadog.trace.api.DDTags; import datadog.trace.api.function.TriConsumer; import datadog.trace.api.function.TriFunction; import datadog.trace.api.gateway.Flow; @@ -26,6 +27,7 @@ import datadog.trace.api.gateway.SubscriptionService; import datadog.trace.bootstrap.ActiveSubsystems; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; import datadog.trace.test.junit.utils.config.WithConfig; import java.io.ByteArrayInputStream; @@ -416,6 +418,48 @@ void responseAndRequestCallbacksAreBothInvoked() throws IOException { assertTraces(trace(span().type(DDSpanTypes.SERVERLESS).error(false))); } + @Test + void invocationSpanCarriesHttpTags() throws IOException { + String eventJson = + "{" + + "\"resource\": \"/api/users/{id}\"," + + "\"path\": \"/api/users/123\"," + + "\"httpMethod\": \"GET\"," + + "\"queryStringParameters\": {\"q\": \"hello\"}," + + "\"headers\": {\"Host\": \"api.example.com\"," + + " \"User-Agent\": \"test-agent\"}," + + "\"requestContext\": {" + + " \"httpMethod\": \"GET\"," + + " \"requestId\": \"req-tags\"," + + " \"domainName\": \"api.example.com\"," + + " \"identity\": {\"sourceIp\": \"127.0.0.1\"}" + + "}" + + "}"; + + ByteArrayInputStream input = + new ByteArrayInputStream(eventJson.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + new HandlerStreamingWithApiGwResponse().handleRequest(input, output, newContext()); + + assertTraces( + trace( + span() + .type(DDSpanTypes.SERVERLESS) + .error(false) + .tags( + defaultTags(), + tag("request_id", is(REQUEST_ID)), + tag(Tags.HTTP_METHOD, is("GET")), + // The tracer tags http.url without the query string; QueryObfuscator + // obfuscates http.query.string and re-appends it as the trace is serialised + tag(Tags.HTTP_URL, is("https://api.example.com/api/users/123?q=hello")), + tag(DDTags.HTTP_QUERY, is("q=hello")), + tag(Tags.HTTP_USER_AGENT, is("test-agent")), + tag(Tags.HTTP_ROUTE, is("/api/users/{id}")), + tag(Tags.HTTP_HOSTNAME, is("api.example.com")), + tag(Tags.HTTP_STATUS, is(200))))); + } + @Test void responseCallbacksFireBeforeRequestEnded() throws IOException { List callOrder = new ArrayList<>(); diff --git a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaAppSecHandler.java b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaAppSecHandler.java index ea01cf2bb07..84d25ccdf8e 100644 --- a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaAppSecHandler.java +++ b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaAppSecHandler.java @@ -3,12 +3,16 @@ import static datadog.trace.api.gateway.Events.EVENTS; import static datadog.trace.lambda.LambdaEventParser.MAX_EVENT_SIZE; import static datadog.trace.lambda.LambdaEventParser.buildFullPath; +import static datadog.trace.lambda.LambdaEventParser.findHeader; import static datadog.trace.lambda.LambdaEventParser.parseEvent; import static datadog.trace.lambda.LambdaEventParser.parseJsonValue; import static datadog.trace.lambda.LambdaEventParser.parseResponse; import datadog.logging.RatelimitedLogger; +import datadog.trace.api.Config; +import datadog.trace.api.DDTags; import datadog.trace.api.ProductTraceSource; +import datadog.trace.api.TagMap; import datadog.trace.api.appsec.AppSecContext; import datadog.trace.api.function.TriConsumer; import datadog.trace.api.gateway.BlockResponseFunction; @@ -23,9 +27,11 @@ import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData; +import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities; import datadog.trace.bootstrap.instrumentation.api.TagContext; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; +import datadog.trace.bootstrap.instrumentation.api.URIUtils; import datadog.trace.lambda.LambdaEventParser.LambdaRequestData; import datadog.trace.lambda.LambdaEventParser.LambdaResponseData; import datadog.trace.lambda.LambdaEventParser.LambdaTriggerType; @@ -42,8 +48,9 @@ import org.slf4j.LoggerFactory; /** - * Handles AppSec processing for AWS Lambda invocations. Extracts Lambda event data and invokes - * AppSec gateway callbacks. + * Handles AppSec processing for AWS Lambda invocations: invokes the AppSec gateway callbacks for + * the event and the handler response, and derives the HTTP span tags from the event. Payload + * parsing is delegated to {@link LambdaEventParser}. */ public class LambdaAppSecHandler { @@ -55,12 +62,13 @@ public class LambdaAppSecHandler { private static final ThreadLocal CURRENT_TRIGGER_TYPE = new ThreadLocal<>(); /** - * Process AppSec request data at the start of a Lambda invocation. Extract event data and invokes - * all relevant AppSec gateway callbacks. + * Processes AppSec request data at the start of a Lambda invocation: invokes all relevant AppSec + * gateway callbacks on the parsed event, and, for recognised HTTP triggers, applies the HTTP tags + * to the returned context so they land on the invocation span at creation. * * @param event the Lambda event object - * @return AgentSpanContext containing AppSec data, or null if AppSec is disabled or processing - * fails + * @return a {@link TagContext} carrying the AppSec request context and the HTTP tags, or null if + * AppSec is disabled, the event is not a parseable payload, or processing fails */ public static AgentSpanContext processRequestStart(Object event) { if (!ActiveSubsystems.APPSEC_ACTIVE) { @@ -83,7 +91,19 @@ public static AgentSpanContext processRequestStart(Object event) { return null; } CURRENT_TRIGGER_TYPE.set(eventData.triggerType); - return processAppSecRequestData(eventData); + // v2 payloads carry the request line verbatim; the others expose the path and a decoded + // parameter map only, so the query string has to be rebuilt from them + String fullPath = eventData.rawUri; + if (fullPath == null && eventData.path != null) { + fullPath = buildFullPath(eventData.path, eventData.queryParameters); + } + LambdaURIDataAdapter uriAdapter = + new LambdaURIDataAdapter(fullPath, eventData.headers, eventData.host); + AgentSpanContext context = processAppSecRequestData(eventData, uriAdapter); + if (context instanceof TagContext && eventData.triggerType.isHttp()) { + applyHttpTags((TagContext) context, eventData, uriAdapter); + } + return context; } catch (Exception e) { log.debug("Failed to process AppSec request data", e); return null; @@ -91,7 +111,8 @@ public static AgentSpanContext processRequestStart(Object event) { } /** - * Invokes the requestEnded gateway callback to add AppSec data to the span. + * Invokes the requestEnded gateway callback to add AppSec data to the span, propagates the + * sampling decision of trace-tagging rules, and clears the per-invocation state. * * @param span the current span */ @@ -129,8 +150,9 @@ public static void processRequestEnd(AgentSpan span) { } /** - * Process response data through WAF before the request context is closed. Extracts status code, - * headers, and body from the Lambda response and fires the corresponding gateway events. + * Processes response data through the WAF before the request context is closed: fires the + * response gateway events with the status code, headers and body parsed from the Lambda response, + * and sets {@code http.status_code} on the span. Only applies to recognised HTTP triggers. * * @param span the current span * @param result the Lambda handler result (expected to be a ByteArrayOutputStream) @@ -184,6 +206,13 @@ public static void processResponseData(AgentSpan span, Object result) { // (statusCode remains 0, so the responseStarted guard below will not fire). } + // The only HTTP tag set on the exit path: the status does not exist at span creation. + if (responseData.statusCode > 0) { + span.setHttpStatusCode(responseData.statusCode); + boolean isError = Config.get().getHttpServerErrorStatuses().get(responseData.statusCode); + span.setError(isError, ErrorPriorities.HTTP_SERVER_DECORATOR); + } + RequestContext requestContext = span.getRequestContext(); if (requestContext == null) { log.debug("Span has no RequestContext, skipping response processing"); @@ -237,11 +266,13 @@ public static void processResponseData(AgentSpan span, Object result) { } /** - * Merge AppSec context data into extension context. + * Merges the AppSec request context data and the HTTP tags into the context returned by the + * Lambda Extension, which is the one that survives and seeds the invocation span. * - * @param extensionContext context from extension - * @param appSecContext context containing AppSec data - * @return merged context + * @param extensionContext context from the extension, may be null when no extension is in the + * path + * @param appSecContext context returned by {@link #processRequestStart(Object)}, may be null + * @return the surviving context: the extension one when both are present */ public static AgentSpanContext mergeContexts( AgentSpanContext extensionContext, AgentSpanContext appSecContext) { @@ -261,6 +292,15 @@ public static AgentSpanContext mergeContexts( if (appSecData != null) { merged.withRequestContextDataAppSec(appSecData); } + // The extension context is the one that survives, so the HTTP tags applied to the AppSec + // context have to be carried over: CoreTracer copies them onto the span at creation. + // The AppSec-derived values win on a key collision. No collision is reachable today: the + // extension context only carries tags for headers mapped through + // DD_TRACE_REQUEST_HEADER_TAGS + // (ContextInterpreter.handleTags), and those would have to be mapped onto an http.* key. + for (TagMap.EntryReader tag : extracted.getTags()) { + merged.putTag(tag.tag(), tag.stringValue()); + } return merged; } @@ -271,7 +311,54 @@ public static AgentSpanContext mergeContexts( return extensionContext; } - private static AgentSpanContext processAppSecRequestData(LambdaRequestData eventData) { + /** + * Writes the HTTP tags derived from the Lambda event onto the context that will seed the + * invocation span. Transcribed from {@code HttpServerDecorator.doOnRequest}, minus the client IP + * tags, {@code span.kind} and {@code http.fragment}. + */ + static void applyHttpTags(TagContext ctx, LambdaRequestData req, LambdaURIDataAdapter url) { + // The synthetic "WEBSOCKET" method stays inside the AppSec path; none is fabricated here. + if (req.method != null && req.triggerType != LambdaTriggerType.API_GATEWAY_V2_WEBSOCKET) { + ctx.putTag(Tags.HTTP_METHOD, req.method); + } + + if (req.host != null) { + // No query string: QueryObfuscator obfuscates DDTags.HTTP_QUERY and re-appends it here. + ctx.putTag( + Tags.HTTP_URL, URIUtils.buildURL(url.scheme(), url.host(), url.port(), url.path())); + } + + String query = url.rawQuery(); + if (query != null && !query.isEmpty() && Config.get().isHttpServerTagQueryString()) { + ctx.putTag(DDTags.HTTP_QUERY, query); + } + + String userAgent = findHeader(req.headers, "user-agent"); + if (userAgent != null) { + ctx.putTag(Tags.HTTP_USER_AGENT, userAgent); + } + + if (req.route != null) { + ctx.putTag(Tags.HTTP_ROUTE, req.route); + } + + // Deliberately a different host from the one in http.url, as in the decorator + String forwardedHost = findHeader(req.headers, "x-forwarded-host"); + String hostname = forwardedHost != null ? forwardedHost : req.host; + if (hostname != null) { + ctx.putTag(Tags.HTTP_HOSTNAME, hostname); + } + } + + /** + * Fires the request-phase gateway callbacks against a {@link TemporaryRequestContext}, since the + * span does not exist yet, and returns the context carrying the resulting AppSec request context. + * + * @return the context to hand back to the tracer, or null if AppSec registered no {@code + * requestStarted} callback + */ + private static AgentSpanContext processAppSecRequestData( + LambdaRequestData eventData, LambdaURIDataAdapter uriAdapter) { AgentTracer.TracerAPI tracer = AgentTracer.get(); Supplier> requestStartedCallback = tracer.getCallbackProvider(RequestContextSlot.APPSEC).getCallback(EVENTS.requestStarted()); @@ -298,9 +385,6 @@ private static AgentSpanContext processAppSecRequestData(LambdaRequestData event .getCallbackProvider(RequestContextSlot.APPSEC) .getCallback(EVENTS.requestMethodUriRaw()); if (methodUriCallback != null) { - // Reconstruct full path with query string for AppSec analysis - String fullPath = buildFullPath(eventData.path, eventData.queryParameters); - LambdaURIDataAdapter uriAdapter = new LambdaURIDataAdapter(fullPath, eventData.headers); methodUriCallback.apply(requestContext, eventData.method, uriAdapter); } else { log.debug("requestMethodUriRaw callback is null"); diff --git a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaEventParser.java b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaEventParser.java index 84ed21b5dfb..fdb03e6edbb 100644 --- a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaEventParser.java +++ b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaEventParser.java @@ -11,6 +11,7 @@ import java.util.Base64; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -20,7 +21,8 @@ /** * Parses AWS Lambda invocation payloads: detects the trigger type and extracts the HTTP request and - * response data the AppSec gateway callbacks need. Contains no AppSec logic. + * response data needed by the AppSec gateway callbacks and by the HTTP span tags. Contains no + * AppSec logic. */ final class LambdaEventParser { @@ -122,11 +124,7 @@ static LambdaResponseData parseResponse(String json) { } // Extract headers — keys are lowercased to normalise casing across API GW / ALB variants - Map headers = new HashMap<>(); - Map rawHeaders = extractStringMap(response.get("headers")); - for (Map.Entry entry : rawHeaders.entrySet()) { - headers.put(entry.getKey().toLowerCase(Locale.ROOT), entry.getValue()); - } + Map headers = extractHeaderMap(response.get("headers")); // Merge multiValueHeaders if present (API GW v1 / ALB), also lowercasing keys Object multiValueHeadersObj = response.get("multiValueHeaders"); @@ -242,8 +240,12 @@ static LambdaTriggerType detectTriggerType(Map event) { private static LambdaRequestData extractApiGatewayV1Data(Map event) { Map headers = extractHeaders(event.get("headers")); Map pathParameters = extractPathParameters(event.get("pathParameters")); + // Preferred over queryStringParameters, which keeps only the last value of a repeated key Map> queryParameters = - extractQueryParameters(event.get("queryStringParameters")); + extractMultiValueQueryParameters(event.get("multiValueQueryStringParameters")); + if (queryParameters.isEmpty()) { + queryParameters = extractQueryParameters(event.get("queryStringParameters")); + } Object body = extractBody(event); Map requestContext = (Map) event.get("requestContext"); @@ -266,7 +268,11 @@ private static LambdaRequestData extractApiGatewayV1Data(Map eve LambdaTriggerType.API_GATEWAY_V1_REST, pathParameters, queryParameters, - body); + body, + extractHost(requestContext, headers), + // REST APIs expose the parameterized route as the top-level "resource" + stringOrNull(event.get("resource")), + null); } /** Extracts data from API Gateway v2 (HTTP API) or Lambda URL event */ @@ -301,7 +307,27 @@ private static LambdaRequestData extractApiGatewayV2HttpData( triggerType, pathParameters, queryParameters, - body); + body, + extractHost(requestContext, headers), + extractRouteKey(requestContext), + extractRawUri(event)); + } + + /** + * Reassembles the verbatim request line from {@code rawPath} and {@code rawQueryString}, or null + * when the payload has no {@code rawPath}, so the caller falls back to rebuilding the path. + */ + private static String extractRawUri(Map event) { + String rawPath = stringOrNull(event.get("rawPath")); + if (rawPath == null) { + return null; + } + // Present but empty whenever the request carried no query string + String rawQueryString = stringOrNull(event.get("rawQueryString")); + if (rawQueryString == null || rawQueryString.isEmpty()) { + return rawPath; + } + return rawPath + '?' + rawQueryString; } /** Extracts data from API Gateway v2 WebSocket event */ @@ -334,7 +360,11 @@ private static LambdaRequestData extractApiGatewayV2WebSocketData(Map rawHeaders = (Map) multiValueHeadersObj; for (Map.Entry entry : rawHeaders.entrySet()) { if (entry.getKey() != null && entry.getValue() != null) { - String key = String.valueOf(entry.getKey()); + // Lowercased for the same reason as extractHeaders + String key = String.valueOf(entry.getKey()).toLowerCase(Locale.ROOT); if (entry.getValue() instanceof List) { List values = (List) entry.getValue(); // Join multiple values with comma @@ -363,6 +394,10 @@ private static LambdaRequestData extractAlbData( } } } + if (headers.isEmpty()) { + // multiValueHeaders was present but unusable — fall back to the single-value map + headers = extractHeaders(event.get("headers")); + } } else { headers = extractHeaders(event.get("headers")); } @@ -374,6 +409,10 @@ private static LambdaRequestData extractAlbData( if (triggerType == LambdaTriggerType.ALB_MULTI_VALUE) { queryParameters = extractMultiValueQueryParameters(event.get("multiValueQueryStringParameters")); + if (queryParameters.isEmpty()) { + // ALB_MULTI_VALUE is classified on multiValueHeaders alone, so this map may be absent + queryParameters = extractQueryParameters(event.get("queryStringParameters")); + } } else { queryParameters = extractQueryParameters(event.get("queryStringParameters")); } @@ -389,8 +428,20 @@ private static LambdaRequestData extractAlbData( sourceIp = (commaIdx >= 0 ? xff.substring(0, commaIdx) : xff).trim(); } + // ALB events carry no requestContext.domainName and expose no parameterized route return new LambdaRequestData( - headers, method, path, sourceIp, null, triggerType, pathParameters, queryParameters, body); + headers, + method, + path, + sourceIp, + null, + triggerType, + pathParameters, + queryParameters, + body, + stripPort(findHeader(headers, "host")), + null, + null); } /** Generic data extraction for unknown trigger types (fallback) */ @@ -457,27 +508,87 @@ private static LambdaRequestData extractGenericData(Map event) { } /** - * Generic helper method to extract string key-value pairs from an object. Converts all keys and - * values to strings, filtering out null entries. + * Looks a header up in a map produced by {@link #extractHeaders}, whose keys are already + * lowercased, so {@code lowerCaseName} must be lowercase for a match. */ - private static Map extractStringMap(Object mapObj) { - Map result = new HashMap<>(); - if (mapObj instanceof Map) { - Map rawMap = (Map) mapObj; - for (Map.Entry entry : rawMap.entrySet()) { - if (entry.getKey() != null && entry.getValue() != null) { - String key = String.valueOf(entry.getKey()); - String value = String.valueOf(entry.getValue()); - result.put(key, value); - } + static String findHeader(Map headers, String lowerCaseName) { + return headers == null ? null : headers.get(lowerCaseName); + } + + /** + * Extracts the host the request was addressed to, used as the authority of {@code http.url}, + * preferring {@code requestContext.domainName} over the {@code Host} header as the extension + * does. + */ + private static String extractHost(Map requestContext, Map headers) { + if (requestContext != null) { + String domainName = stringOrNull(requestContext.get("domainName")); + if (domainName != null) { + return domainName; } } - return result; + return stripPort(findHeader(headers, "host")); } - /** Helper method to extract headers from event */ + /** + * Removes a trailing {@code :port}, leaving a bracketed IPv6 literal alone unless it too carries + * one. The port is tracked separately, from {@code x-forwarded-port}, so a {@code Host} of {@code + * example.com:8080} would otherwise have {@code URIUtils.buildURL} emit it twice. + */ + private static String stripPort(String host) { + if (host == null) { + return null; + } + int colon = host.lastIndexOf(':'); + return colon > 0 && colon > host.lastIndexOf(']') ? host.substring(0, colon) : host; + } + + /** + * Extracts the parameterized route from an API Gateway v2 {@code requestContext.routeKey}, which + * has the form {@code "GET /users/{id}"}. {@code $default} is a catch-all, not a route. + */ + private static String extractRouteKey(Map requestContext) { + String routeKey = stringOrNull(requestContext.get("routeKey")); + if (routeKey == null || "$default".equals(routeKey)) { + return null; + } + int space = routeKey.indexOf(' '); + String route = space >= 0 ? routeKey.substring(space + 1).trim() : routeKey; + return route.isEmpty() ? null : route; + } + + /** Returns the value as a non-empty trimmed string, or {@code null} if it is neither. */ + private static String stringOrNull(Object value) { + if (!(value instanceof String)) { + return null; + } + String trimmed = ((String) value).trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * Helper method to extract a header map from an untyped JSON object. Converts all keys and values + * to strings, filtering out null entries. Keys are lowercased. + */ + private static Map extractHeaderMap(Object headersObj) { + if (!(headersObj instanceof Map)) { + return new HashMap<>(); + } + Map rawMap = (Map) headersObj; + Map headers = new HashMap<>(rawMap.size() * 2); + for (Map.Entry entry : rawMap.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + headers.put( + String.valueOf(entry.getKey()).toLowerCase(Locale.ROOT), + String.valueOf(entry.getValue())); + } + } + return headers; + } + + /** Helper method to extract headers from event. */ private static Map extractHeaders(Object headersObj) { - Map headers = extractStringMap(headersObj); + Map headers = extractHeaderMap(headersObj); log.debug("Extracted {} headers", headers.size()); if (headers.containsKey("cookie")) { log.debug("Cookie header found with value length: {}", headers.get("cookie").length()); @@ -485,9 +596,18 @@ private static Map extractHeaders(Object headersObj) { return headers; } - /** Helper method to extract path parameters from event */ + /** Helper method to extract path parameters from event. */ private static Map extractPathParameters(Object pathParamsObj) { - Map pathParams = extractStringMap(pathParamsObj); + if (!(pathParamsObj instanceof Map)) { + return new HashMap<>(); + } + Map rawMap = (Map) pathParamsObj; + Map pathParams = new HashMap<>(rawMap.size() * 2); + for (Map.Entry entry : rawMap.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + pathParams.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); + } + } log.debug("Extracted {} path parameters", pathParams.size()); return pathParams; } @@ -495,9 +615,12 @@ private static Map extractPathParameters(Object pathParamsObj) { /** * Helper method to extract query parameters from event. Converts Map to * Map> format expected by AppSec. + * + *

Insertion-ordered so the query string rebuilt by {@link #buildFullPath} follows the event + * rather than varying with hash order between invocations of the same request shape. */ private static Map> extractQueryParameters(Object queryParamsObj) { - Map> result = new HashMap<>(); + Map> result = new LinkedHashMap<>(); if (queryParamsObj instanceof Map) { Map rawMap = (Map) queryParamsObj; for (Map.Entry entry : rawMap.entrySet()) { @@ -515,9 +638,11 @@ private static Map> extractQueryParameters(Object queryPara /** * Helper method to extract multi-value query parameters (used by ALB). Handles Map> format directly. + * + *

Insertion-ordered for the same reason as {@link #extractQueryParameters}. */ private static Map> extractMultiValueQueryParameters(Object queryParamsObj) { - Map> result = new HashMap<>(); + Map> result = new LinkedHashMap<>(); if (queryParamsObj instanceof Map) { Map rawMap = (Map) queryParamsObj; for (Map.Entry entry : rawMap.entrySet()) { @@ -563,8 +688,7 @@ static String buildFullPath(String path, Map> queryParamete } first = false; try { - // URL-encode key and value so that special characters (e.g. '&' inside a value) are not - // mistaken for query string delimiters when AppSec parses the raw query string. + // Encoded so a '&' inside a value is not read as a delimiter when AppSec re-parses it fullPath.append(URLEncoder.encode(key, "UTF-8")); if (value != null) { fullPath.append('=').append(URLEncoder.encode(value, "UTF-8")); @@ -671,7 +795,7 @@ boolean isHttp() { } } - /** Object for Lambda request data needed for AppSec processing */ + /** Data extracted from a Lambda event, for the WAF request callbacks and the HTTP span tags. */ static class LambdaRequestData { final Map headers; final String method; @@ -683,6 +807,19 @@ static class LambdaRequestData { final Map> queryParameters; final Object body; + /** Host the request was addressed to, used as the authority of {@code http.url}. */ + final String host; + + /** Parameterized route, when the trigger exposes one. */ + final String route; + + /** + * Request line as the client sent it, for the API Gateway v2 and Function URL payloads that + * expose it, null otherwise. Rebuilding it from {@link #queryParameters} instead cannot be + * faithful: v2 comma-joins repeated keys, so {@code ?a=1&a=2} comes back as {@code a=1%2C2}. + */ + final String rawUri; + static final LambdaRequestData EMPTY = new LambdaRequestData( Collections.emptyMap(), @@ -705,6 +842,34 @@ static class LambdaRequestData { Map pathParameters, Map> queryParameters, Object body) { + this( + headers, + method, + path, + sourceIp, + sourcePort, + triggerType, + pathParameters, + queryParameters, + body, + null, + null, + null); + } + + LambdaRequestData( + Map headers, + String method, + String path, + String sourceIp, + Integer sourcePort, + LambdaTriggerType triggerType, + Map pathParameters, + Map> queryParameters, + Object body, + String host, + String route, + String rawUri) { this.headers = headers; this.method = method; this.path = path; @@ -714,10 +879,16 @@ static class LambdaRequestData { this.pathParameters = pathParameters; this.queryParameters = queryParameters; this.body = body; + this.host = host; + this.route = route; + this.rawUri = rawUri; } } - /** Data extracted from a Lambda response for WAF analysis */ + /** + * Data extracted from a Lambda handler response, for the WAF response callbacks and {@code + * http.status_code}. + */ static class LambdaResponseData { final int statusCode; final Map headers; diff --git a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java index fde3947b2a6..f3fe6982d17 100644 --- a/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java +++ b/dd-trace-core/src/main/java/datadog/trace/lambda/LambdaURIDataAdapter.java @@ -1,19 +1,30 @@ package datadog.trace.lambda; +import static datadog.trace.lambda.LambdaEventParser.findHeader; + import datadog.trace.bootstrap.instrumentation.api.URIDataAdapterBase; +import java.util.Locale; import java.util.Map; /** * {@link datadog.trace.bootstrap.instrumentation.api.URIDataAdapter} implementation for Lambda - * events, which expose the path and the query string separately rather than as a URI. + * events, which expose the path and the query string separately rather than as a URI. Shared by the + * WAF path, which needs the raw URI, and by the HTTP span tags, which need the URL. + * + *

Lambda events carry no scheme or port either, so both are derived from the {@code + * x-forwarded-*} headers, defaulting to {@code https} and to the scheme's default port. + * + *

Nothing is percent-decoded, so {@code path()} and {@code query()} return the raw strings and + * the {@code raw.resource} / {@code raw.query-string} settings have no effect. */ class LambdaURIDataAdapter extends URIDataAdapterBase { private final String path; private final String query; private final String scheme; + private final String host; private final int port; - LambdaURIDataAdapter(String pathWithQuery, Map headers) { + LambdaURIDataAdapter(String pathWithQuery, Map headers, String host) { if (pathWithQuery != null) { int queryIndex = pathWithQuery.indexOf('?'); if (queryIndex != -1) { @@ -28,10 +39,16 @@ class LambdaURIDataAdapter extends URIDataAdapterBase { this.query = null; } - String forwardedProto = headers != null ? headers.get("x-forwarded-proto") : null; - this.scheme = (forwardedProto != null && !forwardedProto.isEmpty()) ? forwardedProto : "https"; + this.host = host; + + // Lowercased because the port default below and URIUtils.buildURL both compare the scheme + // exactly; whitelisted because X-Forwarded-Proto is client-influenceable and arrives + // comma-joined when duplicated, which would render as "https, http://host/path". + String forwardedProto = findHeader(headers, "x-forwarded-proto"); + String proto = forwardedProto == null ? null : forwardedProto.toLowerCase(Locale.ROOT); + this.scheme = "http".equals(proto) || "https".equals(proto) ? proto : "https"; - String forwardedPort = headers != null ? headers.get("x-forwarded-port") : null; + String forwardedPort = findHeader(headers, "x-forwarded-port"); int parsedPort = -1; if (forwardedPort != null && !forwardedPort.isEmpty()) { try { @@ -39,7 +56,9 @@ class LambdaURIDataAdapter extends URIDataAdapterBase { } catch (NumberFormatException ignored) { } } - this.port = parsedPort > 0 ? parsedPort : 443; + // URIUtils.buildURL only suppresses the port for 80 on http and 443 on https, so the default + // has to follow the scheme or an http URL would leak ":443". + this.port = parsedPort > 0 ? parsedPort : ("http".equals(this.scheme) ? 80 : 443); } @Override @@ -49,7 +68,7 @@ public String scheme() { @Override public String host() { - return null; + return host; } @Override diff --git a/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java b/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java index 20b1d749556..431fcf833be 100644 --- a/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaAppSecHandlerTest.java @@ -9,8 +9,11 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyByte; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; @@ -21,6 +24,7 @@ import static org.mockito.Mockito.when; import datadog.trace.api.Config; +import datadog.trace.api.DDTags; import datadog.trace.api.ProductTraceSource; import datadog.trace.api.appsec.AppSecContext; import datadog.trace.api.function.TriConsumer; @@ -37,6 +41,7 @@ import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData; +import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities; import datadog.trace.bootstrap.instrumentation.api.TagContext; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; @@ -322,8 +327,10 @@ void extractsApiGatewayV1RestDataCorrectly() { assertInstanceOf(TagContext.class, result); assertEquals("POST", capturedMethod[0]); assertEquals("/api/users/123", capturedPath[0]); - assertEquals("application/json", capturedHeaders.get("Content-Type")); - assertEquals("Bearer token123", capturedHeaders.get("Authorization")); + // The event capitalises these; the parser lowercases header names on the way in, as AppSec + // itself does in AppSecRequestContext.addRequestHeader + assertEquals("application/json", capturedHeaders.get("content-type")); + assertEquals("Bearer token123", capturedHeaders.get("authorization")); assertEquals("192.168.1.100", capturedSourceIp[0]); assertEquals(0, capturedSourcePort[0]); assertNotNull(capturedPathParams[0]); @@ -803,62 +810,6 @@ void buildFullPathEncodesSpecialCharactersInQueryParams() { assertTrue(capturedQuery[0].contains("x%3Dy"), "'=' should be encoded as '%3D'"); } - @Test - void extractsSchemeAndPortFromXForwardedHeaders() { - String eventJson = - "{" - + "\"path\": \"/api/test\"," - + "\"headers\": {\"x-forwarded-proto\": \"http\", \"x-forwarded-port\": \"8080\"}," - + "\"requestContext\": {\"httpMethod\": \"GET\", \"requestId\": \"req-123\"}" - + "}"; - ByteArrayInputStream event = createInputStream(eventJson); - - String[] capturedScheme = {null}; - int[] capturedPort = {-1}; - - setupMockCallbacks( - new Callbacks() - .onMethodUri( - (method, uri) -> { - capturedScheme[0] = uri.scheme(); - capturedPort[0] = uri.port(); - })); - - AgentSpanContext result = LambdaAppSecHandler.processRequestStart(event); - - assertNotNull(result); - assertEquals("http", capturedScheme[0]); - assertEquals(8080, capturedPort[0]); - } - - @Test - void fallsBackToHttps443WhenXForwardedHeadersAreAbsent() { - String eventJson = - "{" - + "\"path\": \"/api/test\"," - + "\"headers\": {}," - + "\"requestContext\": {\"httpMethod\": \"GET\", \"requestId\": \"req-123\"}" - + "}"; - ByteArrayInputStream event = createInputStream(eventJson); - - String[] capturedScheme = {null}; - int[] capturedPort = {-1}; - - setupMockCallbacks( - new Callbacks() - .onMethodUri( - (method, uri) -> { - capturedScheme[0] = uri.scheme(); - capturedPort[0] = uri.port(); - })); - - AgentSpanContext result = LambdaAppSecHandler.processRequestStart(event); - - assertNotNull(result); - assertEquals("https", capturedScheme[0]); - assertEquals(443, capturedPort[0]); - } - @Test void handlesInvalidXForwardedPortGracefully() { String eventJson = @@ -1366,12 +1317,6 @@ void processRequestStartHandlesNullPathParamsCallbackGracefully() { assertInstanceOf(TagContext.class, result); } - @Test - void processRequestStartHandlesExceptionDuringJsonParsing() { - ByteArrayInputStream event = createInputStream("{this is not valid JSON at all"); - assertNull(LambdaAppSecHandler.processRequestStart(event)); - } - @Test void processRequestStartHandlesExceptionDuringStreamReading() { ByteArrayInputStream mockStream = @@ -1953,14 +1898,6 @@ void processResponseDataHandlesMalformedJsonResponse() { assertNull(capturedStatus[0]); } - @Test - void processResponseDataHandlesEmptyStringResponse() { - ByteArrayOutputStream result = createOutputStream(""); - AgentSpan span = mock(AgentSpan.class); - LambdaAppSecHandler.processResponseData(span, result); - // no exception expected - } - // ============================================================================ // processResponseData — null individual callback handling // ============================================================================ @@ -2037,10 +1974,345 @@ void extractResponseDataReturnsNullForEmptyString() { assertNull(parseResponse("")); } + // ============================================================================ + // HTTP span tags + // ============================================================================ + + @Test + void appliesHttpTagsForRestApiEvent() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"resource\": \"/users/{id}\", \"path\": \"/users/42\", \"httpMethod\":" + + " \"GET\", \"queryStringParameters\": {\"q\": \"hello\"}, \"headers\":" + + " {\"Host\": \"api.example.com\", \"User-Agent\": \"curl/8.1\"," + + " \"X-Forwarded-Proto\": \"https\", \"X-Forwarded-Port\": \"443\"}," + + " \"requestContext\": {\"httpMethod\": \"GET\", \"domainName\":" + + " \"api.example.com\"}}")); + + Map tags = tagsOf(context); + assertEquals("GET", tags.get(Tags.HTTP_METHOD)); + assertEquals("https://api.example.com/users/42", tags.get(Tags.HTTP_URL)); + assertEquals("q=hello", tags.get(DDTags.HTTP_QUERY)); + assertEquals("curl/8.1", tags.get(Tags.HTTP_USER_AGENT)); + assertEquals("/users/{id}", tags.get(Tags.HTTP_ROUTE)); + assertEquals("api.example.com", tags.get(Tags.HTTP_HOSTNAME)); + } + + @Test + void appliesHttpTagsWithForwardedSchemeAndPort() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"headers\": {\"host\": \"api.example.com\", \"x-forwarded-proto\": \"http\"," + + " \"x-forwarded-port\": \"8080\"}, \"requestContext\": {\"domainName\":" + + " \"api.example.com\", \"routeKey\": \"POST /orders\", \"http\":" + + " {\"method\": \"POST\", \"path\": \"/orders\"}}}")); + + Map tags = tagsOf(context); + assertEquals("POST", tags.get(Tags.HTTP_METHOD)); + assertEquals("http://api.example.com:8080/orders", tags.get(Tags.HTTP_URL)); + assertEquals("/orders", tags.get(Tags.HTTP_ROUTE)); + assertNull(tags.get(DDTags.HTTP_QUERY)); + } + + @Test + void omitsDefaultPortForForwardedHttpScheme() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb.example.com\", \"x-forwarded-proto\": \"http\"}, \"requestContext\":" + + " {\"elb\": {\"targetGroupArn\": \"arn\"}}}")); + + // No x-forwarded-port: the default must follow the scheme, or ":443" leaks into the URL + assertEquals("http://lb.example.com/alb", tagsOf(context).get(Tags.HTTP_URL)); + } + + @Test + void ignoresEmptyForwardedPort() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb.example.com\", \"x-forwarded-proto\": \"http\"," + + " \"x-forwarded-port\": \"\"}, \"requestContext\": {\"elb\":" + + " {\"targetGroupArn\": \"arn\"}}}")); + + // An empty header value takes the same path as a missing one rather than reaching parseInt + assertEquals("http://lb.example.com/alb", tagsOf(context).get(Tags.HTTP_URL)); + } + + @Test + void normalisesForwardedSchemeCasing() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb.example.com\", \"x-forwarded-proto\": \"HTTP\"}, \"requestContext\":" + + " {\"elb\": {\"targetGroupArn\": \"arn\"}}}")); + + // Lowercased before use, or the port default and URIUtils.buildURL both miss and ":443" leaks + assertEquals("http://lb.example.com/alb", tagsOf(context).get(Tags.HTTP_URL)); + } + + @Test + void fallsBackToHttpsForUnrecognisedForwardedScheme() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb.example.com\", \"x-forwarded-proto\": \"https, http\"}," + + " \"requestContext\": {\"elb\": {\"targetGroupArn\": \"arn\"}}}")); + + // Lambda comma-joins duplicate request headers, so a client-supplied X-Forwarded-Proto arrives + // appended to the real one: taken verbatim it would yield "https, http://lb.example.com/alb" + assertEquals("https://lb.example.com/alb", tagsOf(context).get(Tags.HTTP_URL)); + } + + @Test + void keepsRepeatedQueryKeysFromTheV2RawQueryString() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"rawPath\": \"/orders\", \"rawQueryString\": \"a=1&a=2\"," + + " \"queryStringParameters\": {\"a\": \"1,2\"}, \"requestContext\":" + + " {\"domainName\": \"api.example.com\", \"http\": {\"method\": \"GET\"," + + " \"path\": \"/orders\"}}}")); + + // API Gateway comma-joins repeats into queryStringParameters, so rebuilding from that map + // would report a=1%2C2 + assertEquals("a=1&a=2", tagsOf(context).get(DDTags.HTTP_QUERY)); + } + + @Test + void keepsRepeatedQueryKeysFromTheV1MultiValueParameters() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"path\": \"/orders\", \"queryStringParameters\": {\"a\": \"2\"}," + + " \"multiValueQueryStringParameters\": {\"a\": [\"1\", \"2\"]}," + + " \"requestContext\": {\"httpMethod\": \"GET\", \"requestId\": \"r-1\"," + + " \"domainName\": \"api.example.com\"}}")); + + // REST APIs keep only the last value in queryStringParameters, unlike v2 which comma-joins + assertEquals("a=1&a=2", tagsOf(context).get(DDTags.HTTP_QUERY)); + } + + @Test + void keepsPercentEncodingFromTheV2RawPath() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"rawPath\": \"/orders/a%20b\", \"rawQueryString\": \"\", \"requestContext\":" + + " {\"domainName\": \"api.example.com\", \"http\": {\"method\": \"GET\"," + + " \"path\": \"/orders/a b\"}}}")); + + Map tags = tagsOf(context); + assertEquals("https://api.example.com/orders/a%20b", tags.get(Tags.HTTP_URL)); + // An empty rawQueryString means no query string at all + assertNull(tags.get(DDTags.HTTP_QUERY)); + } + + @Test + void doesNotDuplicateAPortAlreadyInTheHostHeader() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb.example.com:8080\", \"x-forwarded-proto\": \"http\"," + + " \"x-forwarded-port\": \"8080\"}, \"requestContext\": {\"elb\":" + + " {\"targetGroupArn\": \"arn\"}}}")); + + // An ALB listener off 80/443 puts the port in Host as well, and buildURL appends it again + assertEquals("http://lb.example.com:8080/alb", tagsOf(context).get(Tags.HTTP_URL)); + } + + @Test + void doesNotDuplicateAPortAlreadyInABracketedIpv6Host() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"[2001:db8::1]:8080\", \"x-forwarded-proto\": \"http\"," + + " \"x-forwarded-port\": \"8080\"}, \"requestContext\": {\"elb\":" + + " {\"targetGroupArn\": \"arn\"}}}")); + + assertEquals("http://[2001:db8::1]:8080/alb", tagsOf(context).get(Tags.HTTP_URL)); + } + + @Test + void hostnameTagPrefersForwardedHostOverUrlHost() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"headers\": {\"host\": \"api.example.com\", \"x-forwarded-host\":" + + " \"public.example.com\"}, \"requestContext\": {\"domainName\":" + + " \"api.example.com\", \"http\": {\"method\": \"GET\", \"path\":" + + " \"/orders\"}}}")); + + Map tags = tagsOf(context); + assertEquals("https://api.example.com/orders", tags.get(Tags.HTTP_URL)); + assertEquals("public.example.com", tags.get(Tags.HTTP_HOSTNAME)); + } + + @Test + void resolvesHeadersRegardlessOfEventCasing() { + setupMockCallbacks(new Callbacks()); + // API Gateway v1 capitalises header names where v2 and ALB send them lowercase + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"resource\": \"/users/{id}\", \"path\": \"/users/42\", \"httpMethod\": \"GET\"," + + " \"headers\": {\"Host\": \"api.example.com\", \"User-Agent\":" + + " \"curl/8.1\", \"X-Forwarded-Proto\": \"http\", \"X-Forwarded-Host\":" + + " \"public.example.com\"}, \"requestContext\": {\"httpMethod\": \"GET\"}}")); + + Map tags = tagsOf(context); + assertEquals("http://api.example.com/users/42", tags.get(Tags.HTTP_URL)); + assertEquals("curl/8.1", tags.get(Tags.HTTP_USER_AGENT)); + assertEquals("public.example.com", tags.get(Tags.HTTP_HOSTNAME)); + } + + @Test + void omitsMethodTagWhenTheEventCarriesNoMethod() { + setupMockCallbacks(new Callbacks()); + // Typed as a REST event by requestContext.requestId alone, so no httpMethod is available + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"path\": \"/users/42\", \"headers\": {\"host\": \"api.example.com\"}," + + " \"requestContext\": {\"requestId\": \"req-1\", \"domainName\":" + + " \"api.example.com\"}}")); + + Map tags = tagsOf(context); + assertNull(tags.get(Tags.HTTP_METHOD)); + // The remaining tags are unaffected: only the method is derived from the missing field + assertEquals("https://api.example.com/users/42", tags.get(Tags.HTTP_URL)); + assertEquals("api.example.com", tags.get(Tags.HTTP_HOSTNAME)); + } + + @Test + void omitsMethodTagForWebSocketEvent() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"requestContext\": {\"connectionId\": \"c1\", \"eventType\": \"MESSAGE\"," + + " \"routeKey\": \"sendMessage\", \"domainName\": \"ws.example.com\"}}")); + + Map tags = tagsOf(context); + assertNull(tags.get(Tags.HTTP_METHOD)); + assertEquals("sendMessage", tags.get(Tags.HTTP_ROUTE)); + assertEquals("https://ws.example.com/sendMessage", tags.get(Tags.HTTP_URL)); + } + + @Test + void omitsRouteTagForAlbEvent() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb-123.eu-west-1.elb.amazonaws.com\", \"user-agent\": \"alb-agent\"}," + + " \"requestContext\": {\"elb\": {\"targetGroupArn\": \"arn\"}}}")); + + Map tags = tagsOf(context); + assertNull(tags.get(Tags.HTTP_ROUTE)); + assertEquals("GET", tags.get(Tags.HTTP_METHOD)); + assertEquals("https://lb-123.eu-west-1.elb.amazonaws.com/alb", tags.get(Tags.HTTP_URL)); + assertEquals("alb-agent", tags.get(Tags.HTTP_USER_AGENT)); + } + + @Test + void appliesNoHttpTagsForNonHttpEvent() { + setupMockCallbacks(new Callbacks()); + AgentSpanContext context = + LambdaAppSecHandler.processRequestStart( + createInputStream("{\"Records\": [{\"eventSource\": \"aws:sqs\", \"body\": \"hi\"}]}")); + + assertTrue(tagsOf(context).isEmpty()); + } + + @Test + void mergeContextsCopiesHttpTagsIntoExtensionContext() { + TagContext appSecContext = new TagContext(); + appSecContext.putTag(Tags.HTTP_URL, "https://api.example.com/users/42"); + appSecContext.putTag(Tags.HTTP_ROUTE, "/users/{id}"); + TagContext extensionContext = new TagContext(); + + AgentSpanContext merged = LambdaAppSecHandler.mergeContexts(extensionContext, appSecContext); + + assertSame(extensionContext, merged); + Map tags = tagsOf(merged); + assertEquals("https://api.example.com/users/42", tags.get(Tags.HTTP_URL)); + assertEquals("/users/{id}", tags.get(Tags.HTTP_ROUTE)); + } + + @Test + void processResponseDataSetsHttpStatusCode() { + LambdaAppSecHandler.setCurrentTriggerType(LambdaTriggerType.API_GATEWAY_V1_REST); + AgentSpan span = setupMockResponseCallbacks(null, null, null, null); + LambdaAppSecHandler.processResponseData( + span, createOutputStream("{\"statusCode\": 201, \"body\": \"created\"}")); + + verify(span).setHttpStatusCode(201); + verify(span).setError(false, ErrorPriorities.HTTP_SERVER_DECORATOR); + } + + @Test + void processResponseDataFlagsServerErrorStatusAsError() { + LambdaAppSecHandler.setCurrentTriggerType(LambdaTriggerType.API_GATEWAY_V1_REST); + AgentSpan span = setupMockResponseCallbacks(null, null, null, null); + LambdaAppSecHandler.processResponseData( + span, createOutputStream("{\"statusCode\": 500, \"body\": \"boom\"}")); + + verify(span).setHttpStatusCode(500); + verify(span).setError(true, ErrorPriorities.HTTP_SERVER_DECORATOR); + } + + @Test + void processResponseDataDoesNotFlagClientErrorStatusAsError() { + LambdaAppSecHandler.setCurrentTriggerType(LambdaTriggerType.API_GATEWAY_V1_REST); + AgentSpan span = setupMockResponseCallbacks(null, null, null, null); + LambdaAppSecHandler.processResponseData( + span, createOutputStream("{\"statusCode\": 404, \"body\": \"nope\"}")); + + verify(span).setHttpStatusCode(404); + verify(span).setError(false, ErrorPriorities.HTTP_SERVER_DECORATOR); + } + + @Test + void processResponseDataLeavesHttpStatusCodeUnsetForNonApiGwResponse() { + LambdaAppSecHandler.setCurrentTriggerType(LambdaTriggerType.LAMBDA_URL); + AgentSpan span = setupMockResponseCallbacks(null, null, null, null); + LambdaAppSecHandler.processResponseData(span, createOutputStream("{\"result\": \"hello\"}")); + + verify(span, never()).setHttpStatusCode(anyInt()); + verify(span, never()).setError(anyBoolean(), anyByte()); + } + // ============================================================================ // Helper Methods // ============================================================================ + private static Map tagsOf(AgentSpanContext context) { + assertInstanceOf(TagContext.class, context); + return new HashMap<>(((TagContext) context).getTags()); + } + private static ByteArrayInputStream createInputStream(String json) { return new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); } diff --git a/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaEventParserTest.java b/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaEventParserTest.java new file mode 100644 index 00000000000..9afd9d9ffa4 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/lambda/LambdaEventParserTest.java @@ -0,0 +1,336 @@ +package datadog.trace.lambda; + +import static datadog.trace.lambda.LambdaEventParser.buildFullPath; +import static datadog.trace.lambda.LambdaEventParser.findHeader; +import static datadog.trace.lambda.LambdaEventParser.parseEvent; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.trace.lambda.LambdaEventParser.LambdaRequestData; +import datadog.trace.lambda.LambdaEventParser.LambdaTriggerType; +import java.util.ArrayList; +import java.util.HashMap; +import org.junit.jupiter.api.Test; + +/** Unit tests for the host and route extraction that feeds the HTTP span tags. */ +class LambdaEventParserTest { + + // ============================================================================ + // API Gateway v1 (REST) + // ============================================================================ + + @Test + void restApiPrefersDomainNameOverHostHeader() { + LambdaRequestData data = + parseEvent( + "{\"resource\": \"/users/{id}\", \"path\": \"/users/42\", \"httpMethod\": \"GET\"," + + " \"headers\": {\"Host\": \"header.example.com\"}," + + " \"requestContext\": {\"httpMethod\": \"GET\", \"domainName\":" + + " \"context.example.com\"}}"); + + assertEquals(LambdaTriggerType.API_GATEWAY_V1_REST, data.triggerType); + assertEquals("context.example.com", data.host); + } + + @Test + void restApiFallsBackToCapitalisedHostHeader() { + LambdaRequestData data = + parseEvent( + "{\"path\": \"/users/42\", \"httpMethod\": \"GET\", \"headers\": {\"Host\":" + + " \"abc123.execute-api.eu-west-1.amazonaws.com\"}, \"requestContext\":" + + " {\"httpMethod\": \"GET\"}}"); + + assertEquals("abc123.execute-api.eu-west-1.amazonaws.com", data.host); + } + + @Test + void restApiRouteIsTheResource() { + LambdaRequestData data = + parseEvent( + "{\"resource\": \"/users/{id}\", \"path\": \"/users/42\", \"httpMethod\": \"GET\"," + + " \"requestContext\": {\"httpMethod\": \"GET\"}}"); + + assertEquals("/users/{id}", data.route); + } + + @Test + void restApiHasNoRouteWhenResourceIsMissing() { + LambdaRequestData data = + parseEvent( + "{\"path\": \"/users/42\", \"httpMethod\": \"GET\", \"requestContext\":" + + " {\"httpMethod\": \"GET\"}}"); + + assertNull(data.route); + } + + // ============================================================================ + // API Gateway v2 (HTTP) and Lambda Function URL + // ============================================================================ + + @Test + void httpApiStripsTheMethodFromTheRouteKey() { + LambdaRequestData data = + parseEvent( + "{\"headers\": {\"host\": \"api.example.com\"}, \"requestContext\": {\"domainName\":" + + " \"api.example.com\", \"routeKey\": \"GET /users/{id}\", \"http\": {\"method\":" + + " \"GET\", \"path\": \"/users/42\"}}}"); + + assertEquals(LambdaTriggerType.API_GATEWAY_V2_HTTP, data.triggerType); + assertEquals("api.example.com", data.host); + assertEquals("/users/{id}", data.route); + } + + @Test + void httpApiDropsTheDefaultRouteKey() { + LambdaRequestData data = + parseEvent( + "{\"requestContext\": {\"domainName\": \"api.example.com\", \"routeKey\":" + + " \"$default\", \"http\": {\"method\": \"GET\", \"path\": \"/\"}}}"); + + assertNull(data.route); + } + + @Test + void functionUrlHasNoRouteAndTakesHostFromDomainName() { + LambdaRequestData data = + parseEvent( + "{\"requestContext\": {\"domainName\":" + + " \"abc.lambda-url.eu-west-1.on.aws\", \"routeKey\": \"$default\", \"http\":" + + " {\"method\": \"POST\", \"path\": \"/\"}}}"); + + assertEquals(LambdaTriggerType.LAMBDA_URL, data.triggerType); + assertEquals("abc.lambda-url.eu-west-1.on.aws", data.host); + assertNull(data.route); + } + + @Test + void httpApiExposesTheRawRequestLine() { + LambdaRequestData data = + parseEvent( + "{\"rawPath\": \"/users/42\", \"rawQueryString\": \"a=1&a=2\", \"requestContext\":" + + " {\"domainName\": \"api.example.com\", \"http\": {\"method\": \"GET\"," + + " \"path\": \"/users/42\"}}}"); + + assertEquals("/users/42?a=1&a=2", data.rawUri); + } + + @Test + void restApiPrefersTheMultiValueQueryParameters() { + LambdaRequestData data = + parseEvent( + "{\"path\": \"/users\", \"queryStringParameters\": {\"a\": \"2\"}," + + " \"multiValueQueryStringParameters\": {\"a\": [\"1\", \"2\"]}," + + " \"requestContext\": {\"httpMethod\": \"GET\", \"requestId\": \"r-1\"}}"); + + assertEquals(singletonMap("a", asList("1", "2")), data.queryParameters); + } + + @Test + void restApiFallsBackToTheSingleValueQueryParameters() { + LambdaRequestData data = + parseEvent( + "{\"path\": \"/users\", \"queryStringParameters\": {\"a\": \"2\"}," + + " \"requestContext\": {\"httpMethod\": \"GET\", \"requestId\": \"r-1\"}}"); + + assertEquals(singletonMap("a", singletonList("2")), data.queryParameters); + } + + @Test + void restApiExposesNoRawRequestLine() { + // Only v2 payloads carry rawPath/rawQueryString; v1 has to be rebuilt from its parameter map + LambdaRequestData data = + parseEvent( + "{\"path\": \"/users/42\", \"httpMethod\": \"GET\", \"requestContext\":" + + " {\"httpMethod\": \"GET\"}}"); + + assertNull(data.rawUri); + } + + // ============================================================================ + // API Gateway v2 (WebSocket) + // ============================================================================ + + @Test + void webSocketRouteIsTheRawRouteKey() { + LambdaRequestData data = + parseEvent( + "{\"requestContext\": {\"connectionId\": \"c1\", \"eventType\": \"CONNECT\"," + + " \"routeKey\": \"$connect\", \"domainName\": \"ws.example.com\"}}"); + + assertEquals(LambdaTriggerType.API_GATEWAY_V2_WEBSOCKET, data.triggerType); + assertEquals("ws.example.com", data.host); + assertEquals("$connect", data.route); + } + + // ============================================================================ + // ALB + // ============================================================================ + + @Test + void albTakesHostFromHeaderAndHasNoRoute() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb-123.eu-west-1.elb.amazonaws.com\"}, \"requestContext\": {\"elb\":" + + " {\"targetGroupArn\": \"arn\"}}}"); + + assertEquals(LambdaTriggerType.ALB, data.triggerType); + assertEquals("lb-123.eu-west-1.elb.amazonaws.com", data.host); + assertNull(data.route); + } + + @Test + void albStripsThePortFromTheHostHeader() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"lb.example.com:8080\"}, \"requestContext\": {\"elb\": {\"targetGroupArn\":" + + " \"arn\"}}}"); + + // The port is carried separately, by x-forwarded-port + assertEquals("lb.example.com", data.host); + } + + @Test + void albKeepsAnIpv6HostIntact() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\": \"[::1]\"}," + + " \"requestContext\": {\"elb\": {\"targetGroupArn\": \"arn\"}}}"); + + assertEquals("[::1]", data.host); + } + + @Test + void albStripsThePortFromABracketedIpv6Host() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"headers\": {\"host\":" + + " \"[2001:db8::1]:8080\"}, \"requestContext\": {\"elb\": {\"targetGroupArn\":" + + " \"arn\"}}}"); + + assertEquals("[2001:db8::1]", data.host); + } + + @Test + void albMultiValueTakesHostFromMultiValueHeaders() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"multiValueHeaders\": {\"host\":" + + " [\"lb-123.eu-west-1.elb.amazonaws.com\"]}, \"requestContext\": {\"elb\":" + + " {\"targetGroupArn\": \"arn\"}}}"); + + assertEquals(LambdaTriggerType.ALB_MULTI_VALUE, data.triggerType); + assertEquals("lb-123.eu-west-1.elb.amazonaws.com", data.host); + } + + @Test + void albMultiValueFallsBackToSingleValueHeaders() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"multiValueHeaders\":" + + " \"not-a-map\", \"headers\": {\"host\": \"fallback.example.com\"}," + + " \"requestContext\": {\"elb\": {\"targetGroupArn\": \"arn\"}}}"); + + assertEquals(LambdaTriggerType.ALB_MULTI_VALUE, data.triggerType); + assertEquals("fallback.example.com", data.host); + } + + @Test + void albMultiValueFallsBackToSingleValueQueryParameters() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"multiValueHeaders\": {\"host\":" + + " [\"lb.example.com\"]}, \"queryStringParameters\": {\"q\": \"hello\"}," + + " \"requestContext\": {\"elb\": {\"targetGroupArn\": \"arn\"}}}"); + + assertEquals(LambdaTriggerType.ALB_MULTI_VALUE, data.triggerType); + assertEquals(singletonList("hello"), data.queryParameters.get("q")); + } + + @Test + void queryParametersKeepEventOrder() { + LambdaRequestData data = + parseEvent( + "{\"resource\": \"/items\", \"path\": \"/items\", \"queryStringParameters\": {\"z\":" + + " \"1\", \"a\": \"2\", \"m\": \"3\"}, \"requestContext\": {\"httpMethod\":" + + " \"GET\", \"domainName\": \"api.example.com\"}}"); + + // Order drives the rebuilt query string, so it must follow the event, not a hash order + assertEquals(asList("z", "a", "m"), new ArrayList<>(data.queryParameters.keySet())); + assertEquals("/items?z=1&a=2&m=3", buildFullPath(data.path, data.queryParameters)); + } + + // ============================================================================ + // Non-HTTP and malformed payloads + // ============================================================================ + + @Test + void nonHttpEventHasNoHostOrRoute() { + LambdaRequestData data = + parseEvent("{\"Records\": [{\"eventSource\": \"aws:sqs\", \"body\": \"hello\"}]}"); + + assertEquals(LambdaTriggerType.UNKNOWN, data.triggerType); + assertNull(data.host); + assertNull(data.route); + } + + @Test + void malformedPayloadReturnsEmpty() { + assertSame(LambdaRequestData.EMPTY, parseEvent("{not json")); + } + + // ============================================================================ + // Header casing + // ============================================================================ + + @Test + void headerKeysAreLowercasedAtExtraction() { + LambdaRequestData data = + parseEvent( + "{\"resource\": \"/users/{id}\", \"path\": \"/users/42\", \"httpMethod\": \"GET\"," + + " \"headers\": {\"Host\": \"api.example.com\", \"User-Agent\": \"curl/8.1\"}," + + " \"requestContext\": {\"httpMethod\": \"GET\"}}"); + + // findHeader is an exact lookup, so a match proves the keys were lowercased on the way in + assertEquals("api.example.com", findHeader(data.headers, "host")); + assertEquals("curl/8.1", findHeader(data.headers, "user-agent")); + assertEquals(2, data.headers.size()); + } + + @Test + void albMultiValueHeaderKeysAreLowercasedAtExtraction() { + LambdaRequestData data = + parseEvent( + "{\"httpMethod\": \"GET\", \"path\": \"/alb\", \"multiValueHeaders\": {\"Host\":" + + " [\"lb.example.com\"], \"User-Agent\": [\"curl/8.1\"]}, \"requestContext\":" + + " {\"elb\": {\"targetGroupArn\": \"arn\"}}}"); + + assertEquals("lb.example.com", findHeader(data.headers, "host")); + assertEquals("curl/8.1", findHeader(data.headers, "user-agent")); + } + + @Test + void capitalisedCookieHeaderIsMergedWithTheV2CookiesArray() { + // The merge in extractHeadersWithCookies looks up "cookie", which only matches once keys are + // lowercased at extraction — an API Gateway v1 style "Cookie" used to be left as a second entry + LambdaRequestData data = + parseEvent( + "{\"headers\": {\"Cookie\": \"a=1\"}, \"cookies\": [\"b=2\"], \"requestContext\":" + + " {\"domainName\": \"api.example.com\", \"http\": {\"method\": \"GET\"," + + " \"path\": \"/\"}}}"); + + assertEquals("a=1; b=2", findHeader(data.headers, "cookie")); + assertEquals(1, data.headers.size()); + } + + @Test + void findHeaderIsNullSafe() { + assertNull(findHeader(null, "user-agent")); + assertNull(findHeader(new HashMap<>(), "user-agent")); + } +}