Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ docs/ Developer documentation (see below)
| Working with Gradle | [docs/how_to_work_with_gradle.md](docs/how_to_work_with_gradle.md) |
| Bootstrap/premain constraints | [docs/bootstrap_design_guidelines.md](docs/bootstrap_design_guidelines.md) |
| CI/CD workflows | [.github/workflows/README.md](.github/workflows/README.md) |
| AppSec: blocking, WAF API, IG events | [docs/appsec/](docs/appsec/) |

**When working on a topic above, read the linked file first** — they are the source of truth maintained by humans.

Expand Down
65 changes: 65 additions & 0 deletions dd-java-agent/appsec/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# AppSec Module

Extended reference: [docs/appsec/](../../docs/appsec/)

## Adding a new WAF address: 4-file checklist

Every new WAF address requires changes in exactly 4 files. Missing any one causes a silent no-op:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing any one causes a silent no-op -> Maybe we could add a test that checks all these 4 files are coherent in their registered addresses?

Then we wouldn't need to document it at all. If you miss them, you get a test error explaining the issue.


1. `Events.java` -- declare the event type with a unique sequential ID
2. `InstrumentationGateway.java` -- register the callback type in `getCallback()`
3. `KnownAddresses.java` -- declare the `Address<T>` constant and add to `fromString()` switch
4. `GatewayBridge.java` -- subscriber field, `init()` registration, `reset()` null, handler method, `DATA_DEPENDENCIES` entry

`GatewayBridgeIGRegistrationSpecification` verifies that every `DATA_DEPENDENCIES` entry registers
its events in `init()`. If you add a `DATA_DEPENDENCIES` entry but forget the `registerCallback()`
call, that test fails with an explanation.

`KnownAddressesSpecificationForkedTest` verifies that every address is in `fromString()` and that
the total count is up to date.

See [docs/appsec/ig-events.md](../../docs/appsec/ig-events.md) for the full handler pattern including the retry-on-expiry loop.

## `KnownAddressesSpecificationForkedTest` instance count

Every new `Address<?>` declared in `KnownAddresses.java` must be accompanied by incrementing the
expected count in `KnownAddressesSpecificationForkedTest`:

```groovy
Address.instanceCount() == 47 // increment by 1 per new Address
```

The test fails if this is not updated.

## WAF API quick reference

- `server.response.status` must be `Integer`, not `String` (changed in libddwaf v1.28.0)
- gRPC messages must use `runEphemeral()`, not `run()` -- persistent data caches only the first message
- Processor outputs (`_dd.appsec.s.req.body`, fingerprints) come from `ResultWithData.attributes` -- never pass them as input addresses
- `server.request.headers.no_cookies` must not include cookies -- cookies go in `server.request.cookies`

Full type mapping and limits: [docs/appsec/waf-api.md](../../docs/appsec/waf-api.md)

## Callback guard ordering in advice

Fetch ALL callbacks before any early-return guard. A callback fetched after a conditional return
is silently skipped when only that callback is registered:

```java
// Correct
BiFunction<...> bodyCallback = cbp.getCallback(EVENTS.requestBodyProcessed());
BiFunction<...> filenamesCallback = cbp.getCallback(EVENTS.requestFilesFilenames());
BiFunction<...> contentCallback = cbp.getCallback(EVENTS.requestFilesContent());
if (bodyCallback == null && filenamesCallback == null && contentCallback == null) return;
```

## Blocking model per framework

| Framework | Call `effectivelyBlocked()`? | Notes |
|---|---|---|
| Netty | NEVER | `BlockingResponseHandler` does it internally |
| Tomcat / Jersey / Jetty | YES, after `t = new BlockingException(...)` | Inside `if (brf != null)` |
| Undertow | YES, but `tryCommitBlockingResponse` is not idempotent | Guard `t == null` before each call |
| Vert.x | NO | Handler-based model |

Full details: [docs/appsec/blocking-patterns.md](../../docs/appsec/blocking-patterns.md)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.datadog.appsec.gateway

import com.datadog.appsec.event.EventDispatcher
import com.datadog.appsec.event.data.KnownAddresses
import datadog.trace.api.gateway.EventType
import datadog.trace.api.gateway.Events
import datadog.trace.api.gateway.SubscriptionService
import datadog.trace.test.util.DDSpecification
Expand Down Expand Up @@ -45,4 +46,41 @@ class GatewayBridgeIGRegistrationSpecification extends DDSpecification {
then:
1 * ig.registerCallback(Events.get().requestFilesContent(), _)
}

// Coherence test: every entry in IGAppSecEventDependencies.DATA_DEPENDENCIES must have its
// events registered in GatewayBridge.init() when the corresponding address is subscribed.
// If you add a new DATA_DEPENDENCIES entry without the matching registerCallback() call in
// init(), this test fails with a message showing which event was missing.
void 'DATA_DEPENDENCIES: events for address #addressKey are registered when address is subscribed'() {
given:
List<EventType<?>> registeredEvents = []
SubscriptionService mockSvc = Mock()
EventDispatcher mockDispatcher = Mock()
_ * mockDispatcher.allSubscribedDataAddresses() >> [address]
mockSvc.registerCallback(_, _) >> { EventType et, Object cb -> registeredEvents << et }

GatewayBridge testBridge = new GatewayBridge(mockSvc, mockDispatcher, null, null, [])
testBridge.init()

expect:
events.every { event ->
assert registeredEvents.contains(event),
"Event '${event.type}' not registered for address '${address.key}'. " +
"Add registerCallback(EVENTS.${event.type}, ...) to GatewayBridge.init() " +
"inside an additionalIGEvents.contains() guard."
true
}

where:
[address, events] << dataDependenciesEntries()
addressKey = address.key
}

private static List<List> dataDependenciesEntries() {
def innerClass = GatewayBridge.declaredClasses.find { it.simpleName == 'IGAppSecEventDependencies' }
def field = innerClass.getDeclaredField('DATA_DEPENDENCIES')
field.accessible = true
Map map = (Map) field.get(null)
return map.collect { addr, evts -> [addr, evts as List] }
}
}
30 changes: 30 additions & 0 deletions dd-java-agent/instrumentation/akka/akka-http/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Akka HTTP AppSec Instrumentation

Extended reference: [docs/appsec/ig-events.md](../../../../docs/appsec/ig-events.md),
[docs/appsec/blocking-patterns.md](../../../../docs/appsec/blocking-patterns.md)

## Two multipart routes must both be instrumented

Akka HTTP has two independent entry points for multipart form data. Any new WAF address that
captures multipart body data must instrument both:

| Route | Entry point | Notes |
|---|---|---|
| Route 1 | `handleMultipartStrictFormData(Multipart$FormData$Strict)` | Has `reqCtx` as local variable; iterates via `getStrictParts()` |
| Route 2 | `handleStrictFormData(StrictForm)` | No `reqCtx` in scope; must obtain via `activeSpan()` |

If only Route 1 is instrumented, multipart requests processed via `formFieldMultiMap` (Route 2)
silently miss the WAF event.

## Do not extract filenames callback dispatch into `UnmarshallerHelpers`

Keep the `requestFilesFilenames` callback dispatch inline in each advice class. The two routes
obtain `reqCtx` differently and handle the `pendingBlock` state separately. When the dispatch was
previously extracted into a shared helper, the filenames callback was silently skipped on Route 2
when the body had already triggered blocking, because the helper did not propagate the pending
`BlockingException` correctly across the two call sites.

## No `effectivelyBlocked()` in Akka advice

Akka HTTP uses the Netty-style blocking model. Do not call `effectivelyBlocked()` in advice.
See [docs/appsec/blocking-patterns.md](../../../../docs/appsec/blocking-patterns.md#netty-never-effectivelyblocked).
3 changes: 3 additions & 0 deletions dd-java-agent/instrumentation/jetty/jetty-appsec/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Jetty AppSec Instrumentation

Extended reference: [docs/appsec/blocking-patterns.md](../../../../docs/appsec/blocking-patterns.md)
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ static void after(
if (action instanceof Flow.Action.RequestBlockingAction) {
Flow.Action.RequestBlockingAction rba = (Flow.Action.RequestBlockingAction) action;
BlockResponseFunction blockResponseFunction = reqCtx.getBlockResponseFunction();
// Unlike Netty (which calls effectivelyBlocked() internally in BlockingResponseHandler),
// Jetty advice must call it explicitly inside if (brf != null).
if (blockResponseFunction != null) {
blockResponseFunction.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (t == null) {
Expand Down
57 changes: 57 additions & 0 deletions dd-java-agent/instrumentation/netty/netty-4.1/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Netty 4.1 AppSec Instrumentation

Extended reference: [docs/appsec/blocking-patterns.md](../../../../docs/appsec/blocking-patterns.md)

## NEVER call `effectivelyBlocked()` in Netty advice

`effectivelyBlocked()` must not appear in any Netty advice class or helper. `BlockingResponseHandler` calls it internally when the blocking response is committed. Calling it in advice causes a double-invocation and closes the span prematurely.

This applies even to the urlencoded body-processed path in `HttpPostRequestDecoderInstrumentation` -- the span is already closed synchronously by `tryCommitBlockingResponse()` before advice can reach the mark.

Correct pattern:
```java
// Netty advice — blocking path
if (brf != null) {
brf.tryCommitBlockingResponse(ctx.getTraceSegment(), rba);
thr = new BlockingException("...");
// do NOT call ctx.getTraceSegment().effectivelyBlocked()
}
```

## `thr` creation must be inside `if (brf != null)`

If `thr = new BlockingException(...)` is placed outside the `brf != null` guard, it is created even when no blocking action exists. The exception is then thrown unconditionally in `@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)`, producing spurious 500 responses.

## FileUpload content: two-branch read

`HttpData` stores uploads either in memory or on disk depending on size. Always branch on `isInMemory()`:

```java
if (fileUpload.isInMemory()) {
ByteBuf buf = fileUpload.getByteBuf();
// read from buf
} else {
File f = fileUpload.getFile();
try (InputStream is = new FileInputStream(f)) {
// read from stream
}
}
```

Never call `getFile()` on an in-memory upload -- the file does not exist on disk and the call throws.

## `@RequiresRequestContext` + `Config.get()` causes muzzle failure

Do not declare `static final` fields initialized from `Config.get()` in any `@RequiresRequestContext`-annotated advice inner class. Muzzle validates those classes against the instrumented library's classpath (Netty), where `Config` is absent, causing `MuzzleValidationException` in CI.

Move such constants to the helper class declared in `helperClassNames()`:

```java
// Wrong -- in @RequiresRequestContext advice inner class
private static final int MAX_FILES = Config.get().getAppSecMaxFileContentCount();

// Correct -- in NettyMultipartHelper or similar helper
public class NettyMultipartHelper {
static final int MAX_FILES_TO_INSPECT = Config.get().getAppSecMaxFileContentCount();
}
```
48 changes: 48 additions & 0 deletions dd-java-agent/instrumentation/tomcat/tomcat-appsec/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Tomcat AppSec Instrumentation

Extended reference: [docs/appsec/blocking-patterns.md](../../../../docs/appsec/blocking-patterns.md),
[docs/appsec/file-content.md](../../../../docs/appsec/file-content.md),
[docs/appsec/multipart-frameworks.md](../../../../docs/appsec/multipart-frameworks.md)

## MUST call `effectivelyBlocked()` in Tomcat blocking path

Unlike Netty, Tomcat advice must call `effectivelyBlocked()` explicitly. The call must come AFTER
`t = new BlockingException(...)` and both must be inside `if (brf != null)`:

```java
if (brf != null) {
brf.tryCommitBlockingResponse(ctx.getTraceSegment(), rba);
t = new BlockingException("..."); // 1. create first
ctx.getTraceSegment().effectivelyBlocked(); // 2. mark after
}
```

If `effectivelyBlocked()` throws (span already finished), the exception object must already exist
so the advice can still return a non-null value and let the container propagate it.

## `Part` access via reflection: GlassFish restriction does NOT apply here

Tomcat's `ParameterCollector` uses reflection to avoid bytecode references to `javax` vs `jakarta`
Part types. This is safe for Tomcat because the agent classloader is not subject to Java 9+ module
access restrictions for Tomcat's classloader.

For GlassFish, direct cast via `javax.servlet.http.Part` interface is required instead.
See [docs/appsec/multipart-frameworks.md](../../../../docs/appsec/multipart-frameworks.md#glassfishpayara-no-reflection-via-parametercollector).

## `inspectContent` flag must be evaluated before iterating parts

Check whether the `requestFilesContent` callback is registered BEFORE iterating parts, not inside
the loop. This avoids calling `getInputStream()` on every file when no rule uses `files_content`:

```java
boolean inspectContent = cbp.getCallback(EVENTS.requestFilesContent()) != null;
ParameterCollector collector = new ParameterCollector.ParameterCollectorImpl(inspectContent);
for (Object part : parts) {
collector.addPart(part);
}
```

## `helperClassNames()` must list all nested inner classes

ByteBuddy does not auto-discover nested classes. Every `$Inner` class used at runtime must be
explicitly listed in `helperClassNames()` or a `NoClassDefFoundError` will occur silently.
37 changes: 37 additions & 0 deletions dd-java-agent/instrumentation/undertow/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Undertow AppSec Instrumentation

Extended reference: [docs/appsec/blocking-patterns.md](../../../../docs/appsec/blocking-patterns.md),
[docs/appsec/ig-events.md](../../../../docs/appsec/ig-events.md)

## `tryCommitBlockingResponse` is not idempotent in Undertow

Unlike Tomcat/Jersey, Undertow's `tryCommitBlockingResponse` commits the response immediately.
Calling it twice sends a duplicate response and can throw an `IllegalStateException`.

When an advice has two blocking paths (body + filenames), the `t == null` guard must be checked
BEFORE each `tryCommitBlockingResponse` call, not after:

```java
// Wrong -- commits response even if already blocked
if (bodyCallback != null && !map.isEmpty()) {
Flow<Void> flow = bodyCallback.apply(ctx, map);
t = tryBlock(flow, ctx);
}
if (filenamesCallback != null && !filenames.isEmpty()) {
Flow<Void> flow = filenamesCallback.apply(ctx, filenames);
t = tryBlock(flow, ctx); // tryBlock calls tryCommitBlockingResponse again
}

// Correct -- guard prevents second commit
if (bodyCallback != null && !map.isEmpty()) {
Flow<Void> flow = bodyCallback.apply(ctx, map);
t = tryBlock(flow, ctx);
}
if (t == null && filenamesCallback != null && !filenames.isEmpty()) {
Flow<Void> flow = filenamesCallback.apply(ctx, filenames);
t = tryBlock(flow, ctx);
}
```

See [docs/appsec/ig-events.md](../../../docs/appsec/ig-events.md#undertow-trycommitblockingresponse-is-not-idempotent)
for the full explanation of why this differs from other Servlet containers.
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ static void after(
Flow.Action.RequestBlockingAction rba =
(Flow.Action.RequestBlockingAction) filenamesAction;
BlockResponseFunction brf = reqCtx.getBlockResponseFunction();
// tryCommitBlockingResponse is not idempotent in Undertow: guard t == null before
// calling it to avoid committing a second block response when body already blocked.
if (brf != null && t == null) {
boolean success = brf.tryCommitBlockingResponse(reqCtx.getTraceSegment(), rba);
if (success) {
Expand Down
4 changes: 4 additions & 0 deletions dd-java-agent/instrumentation/vertx/vertx-web/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Vert.x Web AppSec Instrumentation

Extended reference: [docs/appsec/ig-events.md](../../../../docs/appsec/ig-events.md),
[docs/appsec/blocking-patterns.md](../../../../docs/appsec/blocking-patterns.md)
Loading