From c1e2326888689b55d2cec9910acb41cc5afbb28f Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Fri, 11 Sep 2026 11:21:51 +0100 Subject: [PATCH 1/2] Re-pin the threat model to 40b07a2 and absorb what merged since 6740cbd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: Nineteen commits landed on main after the model was pinned to `6740cbd`, ten of which touch a claim it makes. One changes a triage line: #1194 documents `idle-timeout` as a bidirectional inactivity timeout that a client sending bytes inside every window keeps alive by design, where §9 called such a connection an evasion and therefore in scope. Two others defend a property the model never stated: that a malformed request on one HTTP/2 stream is answered on that stream and leaves the connection, its HPACK state and its other streams alone (#1252, #1297). Checking the error path for #1246 also surfaced a shipped default the model never mentioned: `error-logging-verbosity = full` writes the failing request target into the log at warning level, independent of the client-facing `verbose-error-messages = off` that P3 rests on. Modification: Re-pin to `40b07a2`. Add P9, HTTP/2 stream isolation, cited to `RequestErrorFlow`, with a note recording the two paths around it that review found and closed: a header that failed to parse unwound out of the HPACK decoder before the dynamic table was updated, desynchronising every later HEADERS frame on the connection (#1252, merged through #1251); and a field the HTTP/1.1 header parser rejects was reported with an exception type nothing on the HTTP/2 side caught, failing the connection (#1297, which also closed the RFC 9113 8.2.1 gap of a CR LF value being accepted silently truncated). Restate P5 and the §9 slow-loris bullet around inactivity rather than evasion, citing the new `reference.conf` and `timeouts.md` wording, and record #1284's leak of one scheduled task per open request as the in-scope shape. Add #1257 to the §9 smuggling bullet beside #1267, and #1281 to the P1 note beside #1259 as the third discard path. Add `max-part-count` (#1266) to §5a and a multipart row to §6, which had no multipart input at all, noting #1279's header-state bleed between parts. Add `error-logging-verbosity` to §5a, a §9 false-friend entry explaining what `verbose-error-messages` does not govern, and §10.9 recommending `simple` where logs are shipped or alerted on. Add two §11 misuse patterns: building `Raw-Request-URI` from request input, and echoing `IllegalRequestContext.rawRequestTarget` unescaped. Turn §5b.4's two concrete examples into a running tally of the defects review has found of that shape. Extend §15 to match. Result: Every claim is verified against `40b07a2`. Provenance is 23 documented / 33 maintainer / 0 inferred. The P1 frame-size gap stays open; #1264 has not merged. Tests: Not run - docs only References: Refs #1194, #1246, #1251, #1252, #1257, #1266, #1279, #1280, #1281, #1284, #1297 --- THREAT_MODEL.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 0e83901b8..211a6b8d5 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -5,7 +5,7 @@ | | | | --- | --- | | **Project** | Apache Pekko HTTP | -| **Written against** | commit `6740cbd`, `main` | +| **Written against** | commit `40b07a2`, `main` | | **Date** | 2026-08-27 | | **Authors** | ASF Security team, at the request of the Pekko PMC | | **Version binding** | Versioned alongside the project. A report against version *N* is triaged against the model as it stood at *N*, not at `main`. | @@ -17,7 +17,7 @@ *(maintainer)* — stated by a Pekko maintainer in review of this document. *(inferred)* — reasoned from code or config defaults, **not yet confirmed**; each has a matching question in §14. -**Confidence:** 20 documented / 32 maintainer / 0 inferred — counting inline tags only. The §5a limits table and the §15 back-map carry a further ~40 documented facts under a single collective citation each, so the document is more evidence-backed than the bare ratio suggests. **Nothing in the document is inferred any more.** Q1-Q9 were answered by a maintainer in review; Q3, Q5 and Q9 were resolved against the source first, the last of these replacing §5's negative claims with a cited scan. Q3, Q4, Q5 and (in part) Q9 are worth noting as corrections rather than confirmations — in each, the draft's claim did not survive contact with the code: Q4 and Q5 had the facts backwards while the proposed triage disposition survived intact; Q3's review found a real defect in the containment check it examined (fixed in [#1218](https://github.com/apache/pekko-http/pull/1218)); and Q9's source scan had missed the one place the main sources write files (§5). Re-pinning the document from `85d7243` to `6740cbd` added two more corrections of the same kind, this time to §8 properties rather than to answers: P1 and P2 were both asserted from `reference.conf` defaults without walking the paths those defaults govern, and each path had three holes in it. P1's fourth is still open and is stated as such rather than papered over (§8). +**Confidence:** 23 documented / 33 maintainer / 0 inferred — counting inline tags only. The §5a limits table and the §15 back-map carry a further ~40 documented facts under a single collective citation each, so the document is more evidence-backed than the bare ratio suggests. **Nothing in the document is inferred any more.** Q1-Q9 were answered by a maintainer in review; Q3, Q5 and Q9 were resolved against the source first, the last of these replacing §5's negative claims with a cited scan. Q3, Q4, Q5 and (in part) Q9 are worth noting as corrections rather than confirmations — in each, the draft's claim did not survive contact with the code: Q4 and Q5 had the facts backwards while the proposed triage disposition survived intact; Q3's review found a real defect in the containment check it examined (fixed in [#1218](https://github.com/apache/pekko-http/pull/1218)); and Q9's source scan had missed the one place the main sources write files (§5). Re-pinning the document from `85d7243` to `6740cbd` added two more corrections of the same kind, this time to §8 properties rather than to answers: P1 and P2 were both asserted from `reference.conf` defaults without walking the paths those defaults govern, and each path had three holes in it. P1's fourth is still open and is stated as such rather than papered over (§8). Apache Pekko HTTP is a Scala/Java toolkit for building HTTP-based services and clients on top of Pekko Streams. It provides a full HTTP/1.1 and HTTP/2 implementation — parsing, connection management, marshalling, and a routing DSL of composable "directives" — as an **embeddable library**, not a standalone server. The application supplies the routes, the authentication, and the deployment. @@ -118,6 +118,7 @@ Pekko HTTP's resistance to malformed and abusive input is almost entirely a func | `max-chunk-size` | `1m` | Single chunk | | `max-chunk-count` | `100000` | Chunks per message | | `max-chunk-ext-length` | `256` | Chunk extension | +| `max-part-count` | `10000` | Parts in one multipart entity — bounds the amplification `max-content-length` does not | | `max-comment-parsing-depth` | `5` | Nested comment recursion | | `max-to-strict-bytes` | `8m` | `toStrict` materialization | | `max-concurrent-streams` | `256` | HTTP/2 concurrent streams | @@ -133,6 +134,7 @@ Pekko HTTP's resistance to malformed and abusive input is almost entirely a func | `uri-parsing-mode` | `strict` | URI leniency | | `cookie-parsing-mode` | `rfc6265` | Cookie leniency | | `verbose-error-messages` | `off` | Whether parse errors leak detail to the client — **secure default** | +| `error-logging-verbosity` | `full` | How much of a rejected request reaches the *server log* — with `full`, the raw request target included (§9) | | `illegal-response-header-name-processing-mode` | `error` | Blocks response-splitting via header names — **secure default** | | `illegal-response-header-value-processing-mode` | `error` | Blocks response-splitting via header values — **secure default** | | `server-header` | `pekko-http/${version}` | Advertises product and version | @@ -172,7 +174,7 @@ Pekko HTTP therefore takes the following position *(maintainer)*: 1. **Defaults are compatibility choices, not security claims.** §5a lists every setting whose default affects the security envelope; §10 lists what the operator must do as a result. Read together they are the contract: Pekko HTTP states what it does not provide, and states what it expects of the operator instead. 2. **A report that a default should be more restrictive is not a vulnerability report.** It is a change request, and is closed as `BY-DESIGN: default-configuration` (§13). This covers the recurring ones: the CORS pair in §5a, `server-header` disclosing a version, and every limit someone considers too generous. 3. **Proposals to change a default are welcome, and belong on the development list.** The PMC will weigh them in good faith on their merits — the compatibility cost, whether a migration path exists, and whether a major version is in flight. Defaults can and do change; they change through project discussion, not as the remediation of a security report. -4. **If an implementation is wrong, Pekko HTTP fixes it.** Where a control does not do what it is documented to do once enabled, that is a defect, in scope, at the severity §8 assigns. **This is the sharp end of the model.** The §5a limits and the §8 properties are only worth what their implementations deliver: a `max-header-list-size` that fails to bound CONTINUATION accumulation, an `illegal-response-header-value-processing-mode = error` that lets a CRLF through, a `safeDirectoryChildPath` that can be walked out of — those are the findings this project wants. Two of those three examples stopped being hypothetical while this document was in review: the containment check could be walked out of ([#1218](https://github.com/apache/pekko-http/pull/1218), §14 Q3) and the CRLF guard did let a trailer through ([#1256](https://github.com/apache/pekko-http/pull/1256), §8 P2). Both were fixed, at the severity §8 assigns, rather than argued down to a configuration question. This posture governs which value ships as the default — never whether the mechanism works. +4. **If an implementation is wrong, Pekko HTTP fixes it.** Where a control does not do what it is documented to do once enabled, that is a defect, in scope, at the severity §8 assigns. **This is the sharp end of the model.** The §5a limits and the §8 properties are only worth what their implementations deliver: a `max-header-list-size` that fails to bound CONTINUATION accumulation, an `illegal-response-header-value-processing-mode = error` that lets a CRLF through, a `safeDirectoryChildPath` that can be walked out of — those are the findings this project wants. Two of those three examples stopped being hypothetical while this document was in review: the containment check could be walked out of ([#1218](https://github.com/apache/pekko-http/pull/1218), §14 Q3) and the CRLF guard did let a trailer through ([#1256](https://github.com/apache/pekko-http/pull/1256), §8 P2). Both were fixed, at the severity §8 assigns, rather than argued down to a configuration question — and the review has kept finding defects of the same shape since, each recorded against the property it bears on: [#1220](https://github.com/apache/pekko-http/pull/1220), [#1255](https://github.com/apache/pekko-http/pull/1255), [#1257](https://github.com/apache/pekko-http/pull/1257), [#1259](https://github.com/apache/pekko-http/pull/1259), [#1266](https://github.com/apache/pekko-http/pull/1266), [#1267](https://github.com/apache/pekko-http/pull/1267), [#1279](https://github.com/apache/pekko-http/pull/1279), [#1281](https://github.com/apache/pekko-http/pull/1281), [#1297](https://github.com/apache/pekko-http/pull/1297). This posture governs which value ships as the default — never whether the mechanism works. **Users are free to strengthen any §5a setting**, and §10 says which ones matter most. What the project will not do is change the shipped value on their behalf. @@ -186,6 +188,8 @@ Pekko HTTP therefore takes the following position *(maintainer)*: | Any bound route | Headers, incl. `Host`, `Cookie` | **Yes** | Pekko HTTP: count/length limits. App: semantic trust | | Any bound route | `X-Forwarded-*`, `X-Real-Ip` | **Yes** — trivially spoofable | **App** — surfaced by `extractClientIP`, never validated; use `extractDirectClientIP` for access control or rate limiting (§14 Q4) | | Any bound route | Entity body (fixed, chunked, streamed) | **Yes** | Pekko HTTP: size/chunk limits. App: content validation | +| Route with a multipart unmarshaller | Body parts — boundaries, per-part headers, part count | **Yes** | Pekko HTTP: `max-part-count`, header limits per part, and each part carries only the headers it declared — a part following one that ended without a separating empty line used to inherit the previous part's headers and header count ([#1279](https://github.com/apache/pekko-http/pull/1279)). App: per-part validation | +| Custom `ParsingErrorHandler` | `IllegalRequestContext.rawRequestTarget` | **Yes** — the bytes that failed to parse | **App** — escape before logging or echoing; documented at the class | | HTTP/2 | Frames, HPACK table, stream IDs | **Yes** | Pekko HTTP: `max-concurrent-streams` | | Route with marshaller | Entity parsed to a domain type | **Yes** | Underlying JSON/XML library + app | | `cors()` | `Origin`, `Access-Control-Request-*` | **Yes** | Operator: §5a CORS config | @@ -219,14 +223,17 @@ Pekko HTTP therefore takes the following position *(maintainer)*: | P2 | **Response splitting is blocked**: illegal response header names and values are `error` by default, and the renderers drop any header whose rendered bytes contain CR, LF or NUL — on the HTTP/1.1 main header block, on chunked-response trailers and chunk extensions, and on the HTTP/2 HPACK path alike | CRLF or NUL in an application-supplied header, trailer or chunk extension reaching the wire | **Critical** | *(documented — `reference.conf`, `Rendering.scala:270`)* | | P3 | **Parse errors do not leak detail to the client** — `verbose-error-messages = off` | Internal parse state or stack detail in a 400 response under defaults | High | *(documented — `reference.conf`)* | | P4 | **Strict URI and RFC6265 cookie parsing by default**, rather than lenient normalization that invites smuggling | Two components disagreeing on a URI or cookie under `strict` | High | *(documented — `reference.conf`)* | -| P5 | **Connection and request lifetimes are bounded** — `idle-timeout 60s`, `request-timeout 20s`, `max-connections 1024`, `pipelining-limit 1` | A client holding resources indefinitely under defaults | High | *(documented — `reference.conf`)* | +| P5 | **Connection and request lifetimes are bounded** — `idle-timeout 60s` closes a connection with no traffic in *either* direction for that long, `request-timeout 20s` bounds handling once a request has been received, `max-connections 1024`, `pipelining-limit 1`. `idle-timeout` is an inactivity timeout and not a receive deadline: a client that sends a byte inside every window keeps the connection alive by design (§9) | A connection with no traffic outliving `idle-timeout`; a received request outliving `request-timeout` with no response; resources held past either bound after the connection is gone | High | *(documented — `reference.conf`, `timeouts.md`)* | | P6 | **HTTP/2 concurrency and stream churn are bounded** — `max-concurrent-streams = 256` caps streams open at once, and `frame-type-throttle` rate-limits the frames that let a peer cycle through that cap: `RST_STREAM` is charged by default at 100 per second with a burst of 100. Concurrency alone is not a bound on *work*, because a reset frees the slot immediately — this is the HTTP/2 Rapid Reset shape (CVE-2023-44487) | Unbounded stream/state growth on one connection, or unbounded stream churn through a bounded concurrency limit | High | *(documented — `reference.conf`)* | | P7 | **HTTP/2 header blocks are bounded** — `max-header-list-size = 64 KiB` caps the decompressed header list *and* the accumulated HEADERS + CONTINUATION fragments, so a header block the peer never terminates with `END_HEADERS` cannot grow without bound; over-limit blocks get `GOAWAY(ENHANCE_YOUR_CALM)` rather than being buffered | Unbounded buffering from a CONTINUATION flood or an oversized header list | **Critical** | *(documented — `reference.conf`)* | | P8 | **Credential comparison is constant-time** where the verifier calls `Credentials.verify` — it compares via `secure_==`, which XOR-accumulates over the full length after a length check, rather than short-circuiting on the first differing byte | Secret recoverable byte-by-byte from response timing against a `verify`-based verifier | High | *(documented — `SecurityDirectives.scala`, `EnhancedByteArray.scala:37`)* | +| P9 | **HTTP/2 streams are isolated from each other's malformed requests**: a request that fails to parse is answered with a `400` on its own stream (`RequestErrorFlow`), and the connection — its HPACK state and every other stream on it — carries on. A connection a proxy multiplexes for many clients is the deployment this protects | One stream's malformed request failing, stalling or desynchronising the connection for the other streams | High | *(documented — `RequestErrorFlow.scala:57-60`)* | -**P1-P7 are default-on properties** — a notable contrast with `apache/pekko`, where the strongest controls must be switched on. P8 is the exception: it holds only for a verifier that calls `Credentials.verify`, which is why §10.4 states it as a downstream responsibility. The boundary of the DoS claim, once this model's largest ambiguity, is now fixed by the §14 Q1 content-vs-volume line. +**P1-P7 and P9 are default-on properties** — a notable contrast with `apache/pekko`, where the strongest controls must be switched on. P8 is the exception: it holds only for a verifier that calls `Credentials.verify`, which is why §10.4 states it as a downstream responsibility. The boundary of the DoS claim, once this model's largest ambiguity, is now fixed by the §14 Q1 content-vs-volume line. -**A note on how P1 came to be stated this precisely.** P1 has the same history as P2, and for the same reason: the draft asserted it from the §5a table, which records what each limit is *supposed* to bound. Three of those bounds did not hold as written, all fixed at the pinned commit. `max-chunk-count` was tested without an `else` branch, so the `StateResult` of `failEntityStream` was discarded and the parser trampolined into the next chunk — reaching the limit emitted an error per remaining chunk in the buffer instead of stopping the parse ([#1220](https://github.com/apache/pekko-http/pull/1220)). `max-header-count` was not incremented on the branch that merges repeated `Connection` headers into one accumulated header, so a message could repeat that header without ever reaching the limit ([#1255](https://github.com/apache/pekko-http/pull/1255)). And on the HTTP/2 side the connection-level buffer accounting only ever went up: `totalBufferedData` was not decremented when a buffered stream was discarded on `RST_STREAM` or on downstream cancellation, so a peer that sent data it never let the handler read ratcheted the counter up permanently — once the leak passed half of `incoming-connection-level-buffer-size` the server stopped replenishing the connection window, it drained to zero, and every stream on that connection stalled ([#1259](https://github.com/apache/pekko-http/pull/1259)). P1 as stated above is a claim about what the parser and the frame handler do when a bound is reached, not about what the table says the bound is for. *(maintainer)* +**A note on P9, which the pinned commit made true in two places.** The property was already the engine's design — `RequestErrorFlow` has answered a `BadRequest` on the one stream since the percent-encoding case in [#59](https://github.com/apache/pekko-http/issues/59) — but two paths around it were found and closed during review. A header field that failed to parse used to unwind straight out of the HPACK decoder, before the entry reached the dynamic table and before the rest of the block was read, so the decoder's table fell out of step with the peer's encoder and every later `HEADERS` frame on the connection decoded to the wrong fields; an unknown method was enough to trigger it ([#1252](https://github.com/apache/pekko-http/pull/1252), merged through [#1251](https://github.com/apache/pekko-http/pull/1251)). And a field the HTTP/1.1 header parser rejects — a NUL in a value, an illegal character in a name, a value over `max-header-value-length` — was reported with that parser's own internal exception type, which nothing on the HTTP/2 side caught, so it failed the decompression stage and with it the connection ([#1297](https://github.com/apache/pekko-http/pull/1297)). The same change closed a §8.2.1 conformance gap: a field value carrying CR LF was handed to the HTTP/1.1 line parser, which stopped at the first CRLF it met and accepted the value silently truncated; a name or value carrying CR, LF or NUL is now a `400`. P9 as stated is a claim about all three paths. *(maintainer)* + +**A note on how P1 came to be stated this precisely.** P1 has the same history as P2, and for the same reason: the draft asserted it from the §5a table, which records what each limit is *supposed* to bound. Three of those bounds did not hold as written, all fixed at the pinned commit. `max-chunk-count` was tested without an `else` branch, so the `StateResult` of `failEntityStream` was discarded and the parser trampolined into the next chunk — reaching the limit emitted an error per remaining chunk in the buffer instead of stopping the parse ([#1220](https://github.com/apache/pekko-http/pull/1220)). `max-header-count` was not incremented on the branch that merges repeated `Connection` headers into one accumulated header, so a message could repeat that header without ever reaching the limit ([#1255](https://github.com/apache/pekko-http/pull/1255)). And on the HTTP/2 side the connection-level buffer accounting only ever went up: `totalBufferedData` was not decremented when a buffered stream was discarded on `RST_STREAM` or on downstream cancellation, so a peer that sent data it never let the handler read ratcheted the counter up permanently — once the leak passed half of `incoming-connection-level-buffer-size` the server stopped replenishing the connection window, it drained to zero, and every stream on that connection stalled ([#1259](https://github.com/apache/pekko-http/pull/1259); a third discard path, the stream-level `shutdown()` that a `FLOW_CONTROL_ERROR` on one stream takes while the connection carries on, leaked the same way and was closed by [#1281](https://github.com/apache/pekko-http/pull/1281)). A fourth bound was missing outright rather than broken: nothing limited how many parts a multipart entity may carry, so a body at `max-content-length` packed with minimal parts ran to well over a hundred thousand of them, each materialised with headers and an entity of its own — `max-part-count` now bounds it ([#1266](https://github.com/apache/pekko-http/pull/1266)). P1 as stated above is a claim about what the parser and the frame handler do when a bound is reached, not about what the table says the bound is for. *(maintainer)* **One bound is missing at the pinned commit, and P1 is stated with that gap open.** The HTTP/2 frame parser reads a frame's 24-bit length field and then takes that many bytes with no upper limit (`Http2FrameParsing.scala:193-200`), so a peer can make it buffer up to 16 MiB - 1 for a single frame. That happens *before* HPACK decoding and before the entity is assembled, so none of the three HTTP/2 buffer bounds in §5a constrain it, and the cost is multiplied by the number of connections. A `max-frame-size` setting is in flight ([#1264](https://github.com/apache/pekko-http/pull/1264)); until it lands, a report that an oversized frame is buffered is `VALID` against P1 rather than a §12 model gap — the bound is claimed here and does not yet exist. *(maintainer)* @@ -249,12 +256,13 @@ Pekko HTTP therefore takes the following position *(maintainer)*: - **`allowed-origins = "*"` does not mean "no credentials are exposed".** Combined with the shipped `allow-credentials = yes`, it echoes the caller's `Origin` (§5a). - **`remote-address-attribute` is not the client IP behind a proxy.** It is the socket peer. - **`max-content-length` is not a global memory bound.** It bounds one entity; concurrent connections multiply it. +- **`verbose-error-messages = off` is not a log-hygiene setting.** It governs what reaches the *client*. What reaches the *server log* is `error-logging-verbosity`, which ships `full`: a request whose target fails to parse has that target written to the log at warning level, under the parser's caret line marking the failing byte (`UriParser.scala:117-120`, `parsing/package.scala:59-61`). Attacker-controlled bytes in the log at an alerting level are a log-injection and log-flooding consideration the operator owns (§10.9). *(documented — `reference.conf`, `ErrorInfo.scala`)* - **A rejection is not a failure.** The routing DSL's rejection mechanism is control flow, not a security control — an unhandled rejection can fall through to a different route. ### Well-known attack classes left to the caller -- **Request smuggling / desync** between a fronting proxy and Pekko HTTP — inherently a two-party property; strict parsing (P4) helps but cannot settle it alone. What Pekko HTTP does own is its own framing decision, and a case where it framed a message by a different rule than a conformant upstream would is a defect here rather than a two-party stalemate: a `Transfer-Encoding` whose value failed to parse was degraded to a `RawHeader`, never reached the modelled `Transfer-Encoding` arm of the header loop, and so left `isChunked` false — with a `Content-Length` also present the message was framed by length, where an upstream that parsed the value framed it as chunked. It is now rejected ([#1267](https://github.com/apache/pekko-http/pull/1267)); the clear cases — `chunked` with a `Content-Length`, an unsupported coding, multiple entries — were already rejected. -- **Slow-loris and connection exhaustion** — `idle-timeout` and `max-connections` (P5) bound what one connection holds and how many are accepted, but exhausting those bounds by volume is disclaimed (§14 Q1). A single connection that evades `idle-timeout` while holding resources is the in-scope version. +- **Request smuggling / desync** between a fronting proxy and Pekko HTTP — inherently a two-party property; strict parsing (P4) helps but cannot settle it alone. What Pekko HTTP does own is its own framing decision, and a case where it framed a message by a different rule than a conformant upstream would is a defect here rather than a two-party stalemate: a `Transfer-Encoding` whose value failed to parse was degraded to a `RawHeader`, never reached the modelled `Transfer-Encoding` arm of the header loop, and so left `isChunked` false — with a `Content-Length` also present the message was framed by length, where an upstream that parsed the value framed it as chunked. It is now rejected ([#1267](https://github.com/apache/pekko-http/pull/1267)); the clear cases — `chunked` with a `Content-Length`, an unsupported coding, multiple entries — were already rejected. A second case of the same shape sat in the chunk-size parser: whitespace tolerated *after* the size digits — illegal, but seen in the wild — was also tolerated *between* them, and hex accumulation continued past it, so `5 0` parsed as `0x50`, 80 bytes, where a proxy that stops at the space reads 5. Interior whitespace is now rejected and trailing whitespace stays tolerated ([#1257](https://github.com/apache/pekko-http/pull/1257)). +- **Slow-loris and connection exhaustion** — `idle-timeout` and `max-connections` (P5) bound what one connection holds and how many are accepted, but exhausting those bounds by volume is disclaimed (§14 Q1). What `idle-timeout` bounds is *inactivity*, in either direction: the documentation states that a client sending bytes at intervals shorter than the timeout keeps the connection alive, that the setting is not a request-receive deadline, and that a hard receive deadline needs a reverse proxy or an application-level entity timeout *(documented — `reference.conf`, `timeouts.md`)*. A trickling client is therefore `BY-DESIGN: property-disclaimed`, not an evasion. The in-scope version is a connection holding resources with **no** traffic that is not closed when the timeout elapses — or a bound whose own cleanup leaks, as the request-timeout path did: a connection that went away with requests still open left one scheduled task per request alive for the full `request-timeout`, keeping the request and the stage reachable, fixed in [#1284](https://github.com/apache/pekko-http/pull/1284). - **Decompression bombs** are *not* wholly left to the caller. Where the application decodes through the routing DSL, `decodeRequest` / `decodeRequestWith` bound the result on both axes: the decoder's per-chunk output is set to `decode-max-bytes-per-chunk` (`1m`) and the decoded stream is wrapped in `withSizeLimit(decode-max-size)` (`8m`), so a small gzip body that expands past the limit fails the stream instead of filling the heap (`CodingDirectives.scala:93-102`). Every `decodeRequest*` overload funnels through that one directive, so the bound is not something the caller can forget to apply. What is left to the caller is decoding done *outside* those directives — calling `Coders.Gzip.decodeMessage` on a request, or decoding a response on the client side, gets no limit — and the choice to raise `decode-max-size`, which is read with `getPossiblyInfiniteBytes` and so can be set to infinite (`RoutingSettingsImpl.scala:44`). - **SSRF** via the client API, where the application takes a URL from a request. - **Path traversal** is left to the caller in some of the file-serving directives and not others, and the split is worth stating precisely (§14 Q3). `getFromDirectory`, `listDirectoryContents` and the `getFromBrowseableDirector*` pair built on them run the unmatched path through `safeDirectoryChildPath`, which applies both a segment filter and canonical containment against the served root — containment is claimed there, so a genuine escape is `VALID` per §5b.4. `getFromResourceDirectory` applies the segment filter alone (`safeJoinPaths`), which is coherent because a class-loader resource name is not a file-system path and has no canonical form to compare, so its guarantee is that no `..` or separator survives, not that the result is below the base. `getFromFile` and `getFromResource` claim nothing at all: they serve a name the application supplies, with no base directory to be contained by, and handing either one request input is a §11 misuse rather than a library defect. Review of this model found exactly such a defect: the containment check compared canonical paths as **strings**, so a symbolic link resolving into a sibling directory whose name shares the served root as a string prefix (`/var/www` vs `/var/www-private`) escaped it — fixed by [#1218](https://github.com/apache/pekko-http/pull/1218), which compares path elements (§14 Q3). One platform limit is documented at the function: containment rests on `File.getCanonicalPath`, which on Windows does not resolve NTFS symbolic links or junctions, so the link-escape class stays open there. What remains the caller's is the surrounding choice: which root is served, and whether the tree under it contains symlinks at all — a choice that carries the containment on Windows. @@ -272,6 +280,7 @@ Pekko HTTP therefore takes the following position *(maintainer)*: 6. **Validate and canonicalize any request-derived path** before passing it to a file-serving directive. 7. **Treat client-API responses from untrusted upstreams as untrusted input.** 8. **Consider `server-header = ""`** if product/version disclosure matters to your threat model. +9. **Consider `error-logging-verbosity = simple`** where logs are shipped or alerted on: the shipped `full` writes the failing request target into the log at warning level (§9). --- @@ -284,6 +293,8 @@ Pekko HTTP therefore takes the following position *(maintainer)*: - **Comparing credentials with `==`** inside an `authenticateBasic` verifier — or reaching for `provideVerify` with a non-constant-time verifier — instead of `Credentials.verify`. - **Passing a request path segment straight to `getFromFile` or `getFromResource`.** Neither filters what it is handed. `getFromResource` is the easier of the two to get wrong: it looks like the resource-side sibling of `getFromResourceDirectory`, but where that directive routes the request path through `safeJoinPaths` before delegating, `getFromResource` passes the name to `classLoader.getResource` with only a trailing-slash check to keep directory resources unserved (`FileAndResourceDirectives.scala:109-129`), so `getFromResource(s"public/$name")` against a directory-backed class loader can be walked out of the intended prefix and into `application.conf` or `logback.xml`. - **Turning `verbose-error-messages = on`** in production to aid debugging. +- **Building a `Raw-Request-URI` header from request input.** The header is an escape hatch that renders the request target verbatim — into the HTTP/1.1 request line with no CR/LF guard (`HttpRequestRendererFactory.scala:52-53`), and since [#1280](https://github.com/apache/pekko-http/pull/1280) as the HTTP/2 `:path`, where the HPACK guard drops a field carrying CR, LF or NUL and so yields a malformed request rather than an injection. Under §6 the header is application-supplied and trusted, which is exactly why it must not carry client bytes. +- **Echoing `IllegalRequestContext.rawRequestTarget` from a custom `ParsingErrorHandler`** into the response or the log without escaping it. The class documents the field as unvalidated, attacker-controlled input (`ParsingErrorHandler.scala:33-35`); the default handler never reads it. --- @@ -391,7 +402,10 @@ Every other document **links** to `SECURITY.md` for the reporting policy and to | Behaves "pretty well" under most known DoS attacks — scoped to *volume* by the §14 Q1 ruling | `security.md` + maintainer ruling | §4, §9, §11a, §13, §14 Q1 | | An enterprise-grade routing solution or LB (httpd, Nginx) is safer | `security.md` | §5, §10.1 | | Report privately per ASF guidelines; subscribe to announce@ | `security.md` | `SECURITY.md`, §1 | -| Parsing limits (`max-uri-length`, `max-header-*`, `max-chunk-*`, …) | `http-core/reference.conf` | §5a, §8 P1 | +| Parsing limits (`max-uri-length`, `max-header-*`, `max-chunk-*`, `max-part-count`, …) | `http-core/reference.conf` | §5a, §8 P1 | +| `idle-timeout` is a bidirectional inactivity timeout, not a request-receive deadline | `http-core/reference.conf`, `timeouts.md` | §8 P5, §9 | +| `error-logging-verbosity = full` logs the failing request target at warning level | `http-core/reference.conf`, `ErrorInfo.scala` | §5a, §9, §10.9 | +| `IllegalRequestContext.rawRequestTarget` is unvalidated, attacker-controlled input | `ParsingErrorHandler.scala` | §6, §11 | | `verbose-error-messages = off` | `http-core/reference.conf` | §5a, §8 P3 | | Illegal response header name/value processing = `error` | `http-core/reference.conf` | §5a, §8 P2 | | `uri-parsing-mode = strict`, `cookie-parsing-mode = rfc6265` | `http-core/reference.conf` | §5a, §8 P4 | @@ -402,6 +416,7 @@ Every other document **links** to `SECURITY.md` for the reporting policy and to | HTTP/2 incoming buffer bounds (`incoming-connection-level-buffer-size = 10 MB`, `incoming-stream-level-buffer-size = 512kB`) and `outgoing-control-frame-buffer-size = 1024` | `http-core/reference.conf` | §5a, §8 P1 | | Decoding limits (`decode-max-bytes-per-chunk = 1m`, `decode-max-size = 8m`), applied by the `decodeRequest*` directives | `http/reference.conf`, `CodingDirectives.scala` | §5a, §9 | | `Credentials.verify` compares via constant-time `secure_==` | `SecurityDirectives.scala`, `EnhancedByteArray.scala` | §7, §8 P8, §9, §10.4, §14 Q5 | +| A request that fails to parse is answered with a `400` on its own HTTP/2 stream | `RequestErrorFlow.scala` | §8 P9 | | `safeDirectoryChildPath` rejects `..`/separator segments and enforces canonical containment (its string-prefix comparison was found defective in review; fixed by [#1218](https://github.com/apache/pekko-http/pull/1218)) | `FileAndResourceDirectives.scala` | §9, §14 Q3 | | `remote-address-attribute = off` | `http-core/reference.conf` | §5, §9, §14 Q4 | | `extractClientIP` reads `X-Forwarded-For`/`X-Real-Ip`; `extractDirectClientIP` reads the attribute alone | `MiscDirectives.scala` | §5, §6, §9, §10.5, §11a, §14 Q4 | From 956c9842b4f9fa682c7f8330b05c06d6b153c7b0 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Fri, 11 Sep 2026 16:07:55 +0100 Subject: [PATCH 2/2] Trim the threat model to what a triager needs; the review history moves to the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: The document had grown to ten thousand words, and the growth was not in its claims but in narrative about how each claim came to be stated: four "how P-n came to be stated" essays under the §8 table, §14 answers that re-argued the reasoning the owning section had already absorbed, §9 bullets that explained each fixed defect's mechanism, a Confidence paragraph recounting which questions turned out to be corrections, and the same fixes listed in three or four places each. Its readers are triagers and the report-generating tools that cite it; they need the properties, the limits, the dispositions and the non-findings, and they need them fast. The review history is real but belongs in the pull request, not in the artefact reporters read. Modification: Cut to 6,500 words with no claim, ruling, disposition, table row or default removed. The four §8 notes become one verification paragraph naming each defect and its PR in a clause; §14 is retitled "Maintainer rulings" and each answer reduced to its ruling, with the reasoning kept only where it is the ruling (Q3's three tiers); §9's essays are cut to the boundary they draw plus one PR reference per fixed case; the Confidence paragraph is replaced by one sentence; the §5b.4 tally, the §12 near-miss narrative and the §5 shutdown-hook caveat are each reduced to a sentence; line-number citations are dropped in favour of file or symbol names, since the pin fixes the commit and the numbers rot on every merge. Every PR a claim depends on is still referenced, once. Result: Same model, 36% shorter, with §14 no longer presenting settled rulings as open questions. Tests: Not run - docs only References: Refs #1295 --- THREAT_MODEL.md | 234 +++++++++++++++++++++--------------------------- 1 file changed, 104 insertions(+), 130 deletions(-) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 211a6b8d5..45a9db0de 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -1,6 +1,6 @@ # Apache Pekko HTTP — Threat Model -**Status:** Reviewed by a Pekko maintainer. **All ten questions in §14 are answered** and are settled model, and §5b records the project's standing position on configuration defaults. One item is referred rather than settled: whether the upstream-coordination sentence in `security.md` should be promoted into `SECURITY.md` (§14 Q10). No claim in this document is now uncited: every assertion is either cited to Pekko HTTP's own source and configuration, or stated by a maintainer. +**Status:** Reviewed by a Pekko maintainer. Every question put to the maintainers (§14) is answered and is settled model; §5b records the project's standing position on configuration defaults. Every claim is either cited to Pekko HTTP's own source and configuration, or stated by a maintainer. One item is referred rather than settled (§14 Q10). | | | | --- | --- | @@ -12,12 +12,7 @@ | **Reporting** | Findings that violate a §8 property should be reported per [`SECURITY.md`](SECURITY.md). Findings under §3 or §9 will be closed citing this document. | | **Companion model** | Pekko HTTP is built on Pekko Streams and Actors. The actor, remoting and cluster layers are modeled in [`apache/pekko`'s `THREAT_MODEL.md`](https://github.com/apache/pekko/blob/main/THREAT_MODEL.md); this document does not restate them. | -**Provenance legend.** -*(documented)* — stated in Pekko HTTP's own docs or `reference.conf` comments, cited. -*(maintainer)* — stated by a Pekko maintainer in review of this document. -*(inferred)* — reasoned from code or config defaults, **not yet confirmed**; each has a matching question in §14. - -**Confidence:** 23 documented / 33 maintainer / 0 inferred — counting inline tags only. The §5a limits table and the §15 back-map carry a further ~40 documented facts under a single collective citation each, so the document is more evidence-backed than the bare ratio suggests. **Nothing in the document is inferred any more.** Q1-Q9 were answered by a maintainer in review; Q3, Q5 and Q9 were resolved against the source first, the last of these replacing §5's negative claims with a cited scan. Q3, Q4, Q5 and (in part) Q9 are worth noting as corrections rather than confirmations — in each, the draft's claim did not survive contact with the code: Q4 and Q5 had the facts backwards while the proposed triage disposition survived intact; Q3's review found a real defect in the containment check it examined (fixed in [#1218](https://github.com/apache/pekko-http/pull/1218)); and Q9's source scan had missed the one place the main sources write files (§5). Re-pinning the document from `85d7243` to `6740cbd` added two more corrections of the same kind, this time to §8 properties rather than to answers: P1 and P2 were both asserted from `reference.conf` defaults without walking the paths those defaults govern, and each path had three holes in it. P1's fourth is still open and is stated as such rather than papered over (§8). +**Provenance legend.** *(documented)* — stated in Pekko HTTP's own docs or `reference.conf`, cited. *(maintainer)* — stated by a Pekko maintainer in review. Nothing in this document is inferred: every claim first drafted from code or configuration was either confirmed by a maintainer or corrected against the source, and several were corrected — the review history is in the pull requests this document links to. Apache Pekko HTTP is a Scala/Java toolkit for building HTTP-based services and clients on top of Pekko Streams. It provides a full HTTP/1.1 and HTTP/2 implementation — parsing, connection management, marshalling, and a routing DSL of composable "directives" — as an **embeddable library**, not a standalone server. The application supplies the routes, the authentication, and the deployment. @@ -30,7 +25,7 @@ Pekko HTTP is a library the application embeds. There is no Pekko HTTP daemon to Caller roles: - **The embedding application** — fully trusted. Defines routes, supplies handlers, chooses configuration. -- **The operator/deployer** — trusted for the instance. Chooses what sits in front of the service (see §4), TLS termination, and limits. +- **The operator/deployer** — trusted for the instance. Chooses what sits in front of the service (§4), TLS termination, and limits. - **The HTTP client** — **untrusted**. This is the adversary the model is mostly about. ### Component families @@ -47,17 +42,17 @@ Caller roles: | Benchmarks | `http-bench-jmh` | — | **no** — §3 | | Lint / build / docs | `http-scalafix`, `docs`, `project`, `scripts`, `legal` | — | **no** — §3 | -*(maintainer — the in/out split is confirmed by the Pekko maintainers; see §14 Q6)* +*(maintainer — §14 Q6)* --- ## §3 Out of scope (explicit non-goals) - **Test kits, benchmarks, scalafix rules, build tooling and documentation sources.** A finding in `http-bench-jmh` or any `*-tests` module is `OUT-OF-MODEL: unsupported-component`. *(maintainer — §14 Q6)* -- **Pekko HTTP is not a WAF, and not an edge-hardened server.** The documentation says so plainly: applications *"should not be exposed to the public internet directly"* and an *"enterprise grade routing solution"* or a load balancer such as Apache HTTP Server or Nginx *"would be safer"* *(documented — `security.md`)*. See §4. -- **Pekko HTTP is not an authentication or authorization system.** It ships `authenticateBasic`, `authenticateOAuth2` and `authorize` directives, but these are *plumbing*: the credential check is a function the application supplies. Pekko HTTP has no user store, no session model, and no policy engine. *(maintainer — §14 Q7)* -- **The actor, stream, remoting and cluster layers** are out of scope here and covered by `apache/pekko`'s threat model. -- **Attackers who already control the embedding process** are out of scope. *(maintainer — §14 Q7)* +- **Pekko HTTP is not a WAF, and not an edge-hardened server.** Applications *"should not be exposed to the public internet directly"*; an *"enterprise grade routing solution"* or a load balancer such as Apache HTTP Server or Nginx *"would be safer"* *(documented — `security.md`)*. See §4. +- **Pekko HTTP is not an authentication or authorization system.** `authenticateBasic`, `authenticateOAuth2` and `authorize` are plumbing: the credential check is a function the application supplies. There is no user store, session model or policy engine. *(maintainer — §14 Q7)* +- **The actor, stream, remoting and cluster layers** are covered by `apache/pekko`'s threat model. +- **Attackers who already control the embedding process.** *(maintainer — §14 Q7)* --- @@ -65,19 +60,19 @@ Caller roles: **The trust boundary is the inbound HTTP request.** Everything derived from wire bytes — request line, headers, cookies, entity, HTTP/2 frames — is attacker-controlled until the application validates it. -Pekko HTTP's documented posture is unusual and important enough to quote in full: +Pekko HTTP's documented posture: > "Pekko HTTP-based applications should not be exposed to the public internet directly. We believe Pekko HTTP behaves pretty well under most known Denial of Service attacks, but if you want the best security, you should use an enterprise grade routing solution. Even using a load balancing solution like an up-to-date version of Apache HTTP Server or Nginx would be safer than exposing Pekko HTTP-based applications directly to the public internet." > — *(documented — `security.md`, "Security model")* -Read carefully, this makes a **graded** claim rather than a binary one: Pekko HTTP asserts it *"behaves pretty well under most known"* DoS attacks — not that it is DoS-proof, and not that DoS is out of scope. That hedge was the single most consequential ambiguity in this model for triage; §14 Q1 has now resolved it into the content-vs-volume line, which is the rule triage actually applies. +This is a graded claim, not a disclaimer of DoS as a class. §14 Q1 resolves it into the content-vs-volume line that triage applies. ### Reachability preconditions per family -- **Core protocol / HTTP/2** — reachable from raw wire bytes. The strongest in-model surface: a parser defect here needs no application cooperation. -- **Routing DSL** — reachable from a request that the application's own routes expose. A finding must name the directive and show a route shape a reasonable application would write. +- **Core protocol / HTTP/2** — reachable from raw wire bytes. A parser defect here needs no application cooperation. +- **Routing DSL** — reachable from a request the application's own routes expose. A finding must name the directive and show a route shape a reasonable application would write. - **Marshalling** — reachable from an entity body **only where the application has bound that marshaller to a route**. Findings in the underlying JSON/XML library belong to that library. -- **CORS** — reachable only where the application has installed the `cors()` directive. It is opt-in, not on by default. See §5a. +- **CORS** — reachable only where the application has installed `cors()`. Opt-in, not on by default. - **Caching** — reachable only where the application has installed a caching directive; cache-key correctness is the sharp edge. --- @@ -85,27 +80,25 @@ Read carefully, this makes a **graded** claim rather than a binary one: Pekko HT ## §5 Assumptions about the environment - **Runtime.** A conformant JVM. Pekko HTTP does not defend against a hostile JVM or in-process attacker. *(maintainer — §14 Q7)* -- **Fronting infrastructure.** The documented expectation is that something sits in front in production *(documented — `security.md`)*. Per §14 Q1 this is load-bearing for *volume* only: the proxy is relied on for flood and slow-loris defence, not for bounding a single request, which is P1's job. -- **TLS.** Pekko HTTP can terminate TLS itself (`HttpsConnectionContext`) and this is supported, though the documented recommendation to front the service (§4) means termination is commonly the proxy's job in production. Cipher and protocol selection come from the JSSE context the application supplies — Pekko HTTP pins nothing and overrides no JDK default, so cipher strength is a deployment property. *(maintainer — §14 Q8)* -- **Client IP.** `remote-address-attribute` ships `off` *(documented — `reference.conf`)*. When on, the attribute reflects the **socket** peer, which behind a proxy is the proxy; forwarding headers never feed it. Header-derived client IP is opt-in at the call site: `extractClientIP` reads `X-Forwarded-For` / `X-Real-Ip` and is therefore client-controllable, while `extractDirectClientIP` reads the attribute alone and is not. Choosing between them is the application's decision (§14 Q4). *(documented — `MiscDirectives.scala`)* +- **Fronting infrastructure.** Something sits in front in production *(documented — `security.md`)*. Per §14 Q1 this is load-bearing for *volume* only: the proxy is relied on for flood and slow-loris defence, not for bounding a single request, which is P1's job. +- **TLS.** Pekko HTTP can terminate TLS itself (`HttpsConnectionContext`); in a fronted deployment termination is commonly the proxy's job. Cipher and protocol selection come from the JSSE context the application supplies — Pekko HTTP pins nothing and overrides no JDK default. *(maintainer — §14 Q8)* +- **Client IP.** `remote-address-attribute` ships `off` *(documented — `reference.conf`)*. When on, the attribute is the **socket** peer — behind a proxy, the proxy. `extractClientIP` reads `X-Forwarded-For` / `X-Real-Ip` and is client-controllable; `extractDirectClientIP` reads the attribute alone and is not (§14 Q4). *(documented — `MiscDirectives.scala`)* ### What Pekko HTTP does not do to its host -Negative claims, rarely written down and therefore verified against the source rather than asserted. The scan below covers the main sources of `http-core`, `http`, `parsing`, `http-caching` and `http-cors` *(documented — source scan, §14 Q9)*: +Verified by scanning the main sources of `http-core`, `http`, `parsing`, `http-caching` and `http-cors` *(documented — source scan, §14 Q9)*: -- **Binds no port until the application asks for one.** Binding is reachable only through `Http().newServerAt(...)` (`Http.scala:156`) and the `ServerBuilder` it returns — `bind`, `bindSync`, `bindFlow`, `connectionSource`; the implementations behind them are `private[http]` (`Http.scala:179-292`). Nothing binds at class or extension initialization. -- **Installs no signal handlers and spawns no child processes.** No `sun.misc.Signal`/`SignalHandler`, `ProcessBuilder` or `Runtime.exec` in the main sources. -- **Registers no JVM shutdown hook.** There is no `addShutdownHook` anywhere in Pekko HTTP's main sources. Any hook an integrator observes comes from the actor system, not from here — see the caveat below. -- **Touches the file system only through directives the application installs.** The disk-serving directives (`getFromFile`, `getFromDirectory`) read. The upload directives **write**: `storeUploadedFile(s)` streams entity bytes to a destination the application's function chooses, and `fileUploadAll` buffers each part into a `Files.createTempFile` temp file registered with `deleteOnExit` (since [#1217](https://github.com/apache/pekko-http/pull/1217) landed, those temp files go into one directory per actor system, removed by a `CoordinatedShutdown` task in the `actor-system-terminate` phase after in-flight requests have drained, in place of the per-file `deleteOnExit` registrations — `FileUploadDirectives.scala:199-243`). Both are reachable only where the application binds them to a route; nothing writes at initialization or outside an installed directive. Beyond `FileUploadDirectives`, the main sources contain no `FileOutputStream`, `Files.write`, `FileWriter` or `createTempFile`. *(The draft claimed Pekko HTTP writes no files at all; the scan missed the upload directives — corrected in review, §14 Q9.)* +- **Binds no port until the application asks for one.** Binding is reachable only through `Http().newServerAt(...)` and the `ServerBuilder` it returns; nothing binds at class or extension initialization. +- **Installs no signal handlers and spawns no child processes.** No `sun.misc.Signal`, `ProcessBuilder` or `Runtime.exec` in the main sources. +- **Registers no JVM shutdown hook.** No `addShutdownHook` in the main sources. The `ActorSystem` it runs on *does* register hooks — `CoordinatedShutdown`'s, and Artery's when remoting is enabled — so an integrator will observe hooks in the process; they belong to `apache/pekko`'s model. *"Pekko HTTP registers no shutdown hook"* is the claim; *"a Pekko HTTP process has no shutdown hook"* is false. *(maintainer — §14 Q9)* +- **Touches the file system only through directives the application installs.** `getFromFile` / `getFromDirectory` read. `storeUploadedFile(s)` writes to a destination the application chooses; `fileUploadAll` buffers parts into temp files in one directory per actor system, removed by a `CoordinatedShutdown` task ([#1217](https://github.com/apache/pekko-http/pull/1217)). Nothing writes at initialization or outside an installed directive; beyond `FileUploadDirectives` the main sources contain no `FileOutputStream`, `Files.write`, `FileWriter` or `createTempFile`. - **Does not mutate process-global state at initialization** — no `System.setProperty`, `Security.setProperty`, `Security.addProvider` or `setDefault(...)`. -**One inherited caveat, highlighted because it is easy to misattribute.** The claims above cover Pekko HTTP's own modules, not the `ActorSystem` it runs on. `apache/pekko` *does* register JVM shutdown hooks — one in `CoordinatedShutdown`, and a second in Artery when remoting is enabled — so an integrator running a Pekko HTTP service **will** observe shutdown hooks in the process. They arrive with the actor system and are modeled in the companion document. **Pekko HTTP itself adds none**, and that is the claim this section makes: *"a Pekko HTTP process has no shutdown hook"* is false, while *"Pekko HTTP registers no shutdown hook"* is true and is what §14 Q9 settles. *(maintainer — §14 Q9)* - --- ## §5a Configuration variants that change the security envelope -Pekko HTTP's resistance to malformed and abusive input is almost entirely a function of `pekko.http.server.parsing.*`, with two entries under `pekko.http.routing.*` bounding what the parsing limits cannot see — the size of an entity *after* it has been decompressed. These are the **documented, shipped** limits *(all documented — `http-core/src/main/resources/reference.conf`, and `http/src/main/resources/reference.conf` for the two `routing` entries)*: +Pekko HTTP's resistance to malformed and abusive input is a function of `pekko.http.server.parsing.*`, plus two `pekko.http.routing.*` entries bounding what the parsing limits cannot see — an entity's size *after* decompression. These are the **documented, shipped** limits *(all documented — `http-core` and `http` `reference.conf`)*: | Setting | Default | What it bounds | | --- | --- | --- | @@ -118,7 +111,7 @@ Pekko HTTP's resistance to malformed and abusive input is almost entirely a func | `max-chunk-size` | `1m` | Single chunk | | `max-chunk-count` | `100000` | Chunks per message | | `max-chunk-ext-length` | `256` | Chunk extension | -| `max-part-count` | `10000` | Parts in one multipart entity — bounds the amplification `max-content-length` does not | +| `max-part-count` | `10000` | Parts in one multipart entity — the amplification `max-content-length` does not bound | | `max-comment-parsing-depth` | `5` | Nested comment recursion | | `max-to-strict-bytes` | `8m` | `toStrict` materialization | | `max-concurrent-streams` | `256` | HTTP/2 concurrent streams | @@ -126,24 +119,24 @@ Pekko HTTP's resistance to malformed and abusive input is almost entirely a func | `incoming-connection-level-buffer-size` | `10 MB` | HTTP/2 incoming data buffered across one connection | | `incoming-stream-level-buffer-size` | `512kB` | HTTP/2 incoming data buffered for one stream | | `outgoing-control-frame-buffer-size` | `1024` | HTTP/2 outgoing control frames queued before the connection fails | -| `frame-type-throttle` | `frame-types = ["reset"]`, `cost = 100`, `burst = 100`, `interval = 1 s` | HTTP/2 frames charged against a rate limit — `RST_STREAM` by default, mitigating HTTP/2 Rapid Reset (CVE-2023-44487) — **secure default** | +| `frame-type-throttle` | `frame-types = ["reset"]`, `cost = 100`, `burst = 100`, `interval = 1 s` | HTTP/2 frames rate-limited — `RST_STREAM` by default, against Rapid Reset (CVE-2023-44487) — **secure default** | | `max-connections` | `1024` | Server connections | | `pipelining-limit` | `1` | In-flight pipelined requests | -| `idle-timeout` | `60 s` | Connection idle | +| `idle-timeout` | `60 s` | Connection inactivity, in either direction (§9) | | `request-timeout` | `20 s` | Per-request handling | | `uri-parsing-mode` | `strict` | URI leniency | | `cookie-parsing-mode` | `rfc6265` | Cookie leniency | | `verbose-error-messages` | `off` | Whether parse errors leak detail to the client — **secure default** | -| `error-logging-verbosity` | `full` | How much of a rejected request reaches the *server log* — with `full`, the raw request target included (§9) | +| `error-logging-verbosity` | `full` | How much of a rejected request reaches the *server log* — with `full`, the raw request target (§9) | | `illegal-response-header-name-processing-mode` | `error` | Blocks response-splitting via header names — **secure default** | | `illegal-response-header-value-processing-mode` | `error` | Blocks response-splitting via header values — **secure default** | | `server-header` | `pekko-http/${version}` | Advertises product and version | | `remote-address-attribute` | `off` | Exposes socket peer address to routes | | `transparent-head-requests` | `off` | HEAD handled as GET | | `routing.decode-max-bytes-per-chunk` | `1m` | Single `ByteString` a decoding directive emits | -| `routing.decode-max-size` | `8m` | Entity size **after** decoding — see §9 | +| `routing.decode-max-size` | `8m` | Entity size **after** decoding (§9) | -**These limits are the model's quantitative spine.** A report that a request *within* every documented limit causes disproportionate resource use is `VALID`; one that simply exceeds a limit is P1 working, and one that needs a limit raised is `OUT-OF-MODEL: non-default-build` (§14 Q1). +**These limits are the model's quantitative spine.** A request *within* every documented limit that causes disproportionate resource use is `VALID`; one that simply exceeds a limit is P1 working; one that needs a limit raised is `OUT-OF-MODEL: non-default-build` (§14 Q1). ### The CORS defaults — ruled (§5b, §14 Q2) @@ -157,24 +150,22 @@ Pekko HTTP's resistance to malformed and abusive input is almost entirely a func | `allowed-methods` | `["GET", "POST", "HEAD", "OPTIONS"]` | | `allow-generic-http-requests` | `yes` | -The module's own documentation states the interaction precisely: *"if parameter is `*` and credentials are not allowed, a `*` is set in `Access-Control-Allow-Origin`. Otherwise, the origins given in the `Origin` request header are echoed."* *(documented — `http-cors/reference.conf`)* - -So with **both** defaults in force, the directive **echoes the requesting `Origin` and allows credentials** — the maximally permissive CORS posture. Two facts bound how alarming that is: the `cors()` directive is **opt-in**, so this affects only applications that chose to enable CORS; and it is a documented, deliberate default rather than an accident. It is nonetheless the most permissive default in the project. Its disposition follows §5b: the shipped value is a compatibility choice, and a request to change it is not a vulnerability report (§14 Q2). *(maintainer — §14 Q2)* +*"If parameter is `*` and credentials are not allowed, a `*` is set in `Access-Control-Allow-Origin`. Otherwise, the origins given in the `Origin` request header are echoed."* *(documented — `http-cors/reference.conf`)* With both defaults in force the directive **echoes the requesting `Origin` and allows credentials** — the most permissive default in the project, bounded by `cors()` being opt-in and the default being deliberate. Its disposition follows §5b: a request to change it is not a vulnerability report. *(maintainer — §14 Q2)* -**That ruling is about the default, never about the enforcement.** Where an operator has restricted `allowed-origins`, the directive must honour what they set, and a case where it did not was fixed at the pinned commit: `validateOrigins` accepted a request as soon as **one** origin in the `Origin` header matched, while the response echoes back **every** origin it was given, so a request pairing an allowed origin with a disallowed one had the disallowed one echoed in `Access-Control-Allow-Origin` ([#1262](https://github.com/apache/pekko-http/pull/1262); simple and actual requests only — the pre-flight path accepts a single origin already). That is a `VALID` finding under §5b.4, and the shape to keep in mind when reading the paragraph above: the permissive default is `BY-DESIGN: default-configuration`, a restrictive setting that fails to restrict is a defect. *(maintainer)* +That ruling covers the default, never the enforcement. Where an operator has restricted `allowed-origins`, the directive must honour it: a request pairing an allowed origin with a disallowed one used to pass while every origin was echoed back ([#1262](https://github.com/apache/pekko-http/pull/1262)) — `VALID` under §5b.4. *(maintainer)* --- ## §5b Security posture: hardening, not secure-by-default -Pekko HTTP is a long-lived toolkit whose deployment base is inherited from Akka HTTP, and `http-cors` carries a second inheritance on top of that — its defaults arrived with the code donated by Lomig Mégard (`legal/CorsNotice.txt`). Its configuration defaults are chosen for compatibility with those deployments, in which operators have already been tasked with fronting the service (§4, §10.1) and choosing limits appropriate to their traffic. Changing a default to a more restrictive value breaks those deployments on upgrade, sometimes without a clear signal as to why — a tightened parsing limit surfaces as requests that used to work now failing with a 4xx. +Pekko HTTP's deployment base is inherited from Akka HTTP, and `http-cors`'s defaults arrived with the code donated by Lomig Mégard (`legal/CorsNotice.txt`). Defaults are chosen for compatibility with those deployments, whose operators already front the service (§4, §10.1) and choose limits for their traffic; tightening a default breaks them on upgrade, often as requests that used to work failing with a 4xx. Pekko HTTP therefore takes the following position *(maintainer)*: -1. **Defaults are compatibility choices, not security claims.** §5a lists every setting whose default affects the security envelope; §10 lists what the operator must do as a result. Read together they are the contract: Pekko HTTP states what it does not provide, and states what it expects of the operator instead. -2. **A report that a default should be more restrictive is not a vulnerability report.** It is a change request, and is closed as `BY-DESIGN: default-configuration` (§13). This covers the recurring ones: the CORS pair in §5a, `server-header` disclosing a version, and every limit someone considers too generous. -3. **Proposals to change a default are welcome, and belong on the development list.** The PMC will weigh them in good faith on their merits — the compatibility cost, whether a migration path exists, and whether a major version is in flight. Defaults can and do change; they change through project discussion, not as the remediation of a security report. -4. **If an implementation is wrong, Pekko HTTP fixes it.** Where a control does not do what it is documented to do once enabled, that is a defect, in scope, at the severity §8 assigns. **This is the sharp end of the model.** The §5a limits and the §8 properties are only worth what their implementations deliver: a `max-header-list-size` that fails to bound CONTINUATION accumulation, an `illegal-response-header-value-processing-mode = error` that lets a CRLF through, a `safeDirectoryChildPath` that can be walked out of — those are the findings this project wants. Two of those three examples stopped being hypothetical while this document was in review: the containment check could be walked out of ([#1218](https://github.com/apache/pekko-http/pull/1218), §14 Q3) and the CRLF guard did let a trailer through ([#1256](https://github.com/apache/pekko-http/pull/1256), §8 P2). Both were fixed, at the severity §8 assigns, rather than argued down to a configuration question — and the review has kept finding defects of the same shape since, each recorded against the property it bears on: [#1220](https://github.com/apache/pekko-http/pull/1220), [#1255](https://github.com/apache/pekko-http/pull/1255), [#1257](https://github.com/apache/pekko-http/pull/1257), [#1259](https://github.com/apache/pekko-http/pull/1259), [#1266](https://github.com/apache/pekko-http/pull/1266), [#1267](https://github.com/apache/pekko-http/pull/1267), [#1279](https://github.com/apache/pekko-http/pull/1279), [#1281](https://github.com/apache/pekko-http/pull/1281), [#1297](https://github.com/apache/pekko-http/pull/1297). This posture governs which value ships as the default — never whether the mechanism works. +1. **Defaults are compatibility choices, not security claims.** §5a lists every setting whose default affects the security envelope; §10 lists what the operator must do as a result. Together they are the contract. +2. **A report that a default should be more restrictive is not a vulnerability report.** It is a change request, closed as `BY-DESIGN: default-configuration` (§13). This covers the CORS pair, `server-header`, and every limit someone considers too generous. +3. **Proposals to change a default are welcome, and belong on the development list.** The PMC weighs compatibility cost, migration path, and whether a major version is in flight. Defaults change through project discussion, not as the remediation of a security report. +4. **If an implementation is wrong, Pekko HTTP fixes it.** A control that does not do what it is documented to do once enabled is a defect, in scope, at the severity §8 assigns. **This is the sharp end of the model:** a `max-header-list-size` that fails to bound CONTINUATION accumulation, an `illegal-response-header-value-processing-mode = error` that lets a CRLF through, a `safeDirectoryChildPath` that can be walked out of — those are the findings this project wants, and the second and third were real and fixed during this review ([#1256](https://github.com/apache/pekko-http/pull/1256), [#1218](https://github.com/apache/pekko-http/pull/1218)). This posture governs which value ships as the default, never whether the mechanism works. **Users are free to strengthen any §5a setting**, and §10 says which ones matter most. What the project will not do is change the shipped value on their behalf. @@ -188,12 +179,12 @@ Pekko HTTP therefore takes the following position *(maintainer)*: | Any bound route | Headers, incl. `Host`, `Cookie` | **Yes** | Pekko HTTP: count/length limits. App: semantic trust | | Any bound route | `X-Forwarded-*`, `X-Real-Ip` | **Yes** — trivially spoofable | **App** — surfaced by `extractClientIP`, never validated; use `extractDirectClientIP` for access control or rate limiting (§14 Q4) | | Any bound route | Entity body (fixed, chunked, streamed) | **Yes** | Pekko HTTP: size/chunk limits. App: content validation | -| Route with a multipart unmarshaller | Body parts — boundaries, per-part headers, part count | **Yes** | Pekko HTTP: `max-part-count`, header limits per part, and each part carries only the headers it declared — a part following one that ended without a separating empty line used to inherit the previous part's headers and header count ([#1279](https://github.com/apache/pekko-http/pull/1279)). App: per-part validation | +| Route with a multipart unmarshaller | Body parts — boundaries, per-part headers, part count | **Yes** | Pekko HTTP: `max-part-count`, header limits per part, each part carrying only its own headers ([#1279](https://github.com/apache/pekko-http/pull/1279)). App: per-part validation | | Custom `ParsingErrorHandler` | `IllegalRequestContext.rawRequestTarget` | **Yes** — the bytes that failed to parse | **App** — escape before logging or echoing; documented at the class | -| HTTP/2 | Frames, HPACK table, stream IDs | **Yes** | Pekko HTTP: `max-concurrent-streams` | +| HTTP/2 | Frames, HPACK table, stream IDs | **Yes** | Pekko HTTP: `max-concurrent-streams`, `frame-type-throttle`, P9 | | Route with marshaller | Entity parsed to a domain type | **Yes** | Underlying JSON/XML library + app | | `cors()` | `Origin`, `Access-Control-Request-*` | **Yes** | Operator: §5a CORS config | -| File-serving directives | Path segments | **Yes** | Pekko HTTP + app — see §14 Q3 | +| File-serving directives | Path segments | **Yes** | Pekko HTTP + app — see §9, §14 Q3 | | Client API | Response from an upstream server | **Yes** if the upstream is untrusted | App: treat responses as untrusted | | Config | `application.conf` | **No** — trusted deployment input | Operator | @@ -203,15 +194,15 @@ Pekko HTTP therefore takes the following position *(maintainer)*: **In scope:** -- **The remote HTTP client.** Can send arbitrary bytes, malformed framing, oversized or deeply-nested input, many concurrent connections, and abusive HTTP/2 frame sequences. The primary adversary — though per §14 Q1 what this adversary achieves through sheer *volume* is the proxy's problem, not the library's. *(maintainer — §14 Q7)* +- **The remote HTTP client.** Arbitrary bytes, malformed framing, oversized or deeply-nested input, many concurrent connections, abusive HTTP/2 frame sequences. The primary adversary — though what it achieves through sheer *volume* is the proxy's problem (§14 Q1). *(maintainer — §14 Q7)* - **A malicious upstream server**, where the application uses the client API against an untrusted endpoint. *(maintainer — §14 Q7)* - **A cross-origin web attacker**, where the application enables CORS. *(maintainer — §14 Q2)* **Explicitly out of scope:** -- **Attackers with code execution in the embedding JVM.** Already inside. +- **Attackers with code execution in the embedding JVM.** - **The embedding application itself.** A route that deliberately leaks data is an application bug. -- **Side-channel observers.** No general timing guarantees are made about routing or parsing. Credential comparison is the exception: `Credentials.Provided.verify` compares in constant time (see §8 P8), so a timing finding there is in scope, while one against an application-supplied `provideVerify` is not. *(documented — `SecurityDirectives.scala`, `EnhancedByteArray.scala`)* +- **Side-channel observers.** No general timing guarantees are made about routing or parsing. Credential comparison is the exception (P8): a timing finding against `Credentials.verify` is in scope; one against an application-supplied `provideVerify` is not. *(documented — `SecurityDirectives.scala`, `EnhancedByteArray.scala`)* --- @@ -219,53 +210,49 @@ Pekko HTTP therefore takes the following position *(maintainer)*: | # | Property & conditions | Violation symptom | Severity | Provenance | | --- | --- | --- | --- | --- | -| P1 | **Inbound messages are bounded** by the §5a limits: reaching a limit stops the parse rather than merely reporting it, every header a message carries counts towards the count limits, and buffered bytes are released from the accounting when the buffer is discarded | OOM or unbounded buffering from input *within* documented limits; a limit reached without the parse stopping | **Critical** | *(documented — `reference.conf`, `HttpMessageParser.scala`, `Http2StreamHandling.scala`)* | -| P2 | **Response splitting is blocked**: illegal response header names and values are `error` by default, and the renderers drop any header whose rendered bytes contain CR, LF or NUL — on the HTTP/1.1 main header block, on chunked-response trailers and chunk extensions, and on the HTTP/2 HPACK path alike | CRLF or NUL in an application-supplied header, trailer or chunk extension reaching the wire | **Critical** | *(documented — `reference.conf`, `Rendering.scala:270`)* | +| P1 | **Inbound messages are bounded** by the §5a limits: reaching a limit stops the parse, every header a message carries counts towards the count limits, and buffered bytes are released from the accounting when the buffer is discarded | OOM or unbounded buffering from input *within* documented limits; a limit reached without the parse stopping | **Critical** | *(documented — `reference.conf`, `HttpMessageParser.scala`, `Http2StreamHandling.scala`)* | +| P2 | **Response splitting is blocked**: illegal response header names and values are `error` by default, and the renderers drop any header whose rendered bytes contain CR, LF or NUL — on the HTTP/1.1 header block, chunked-response trailers and chunk extensions, and the HTTP/2 HPACK path alike | CRLF or NUL in an application-supplied header, trailer or chunk extension reaching the wire | **Critical** | *(documented — `reference.conf`, `Rendering.isIllegalHeaderChar`)* | | P3 | **Parse errors do not leak detail to the client** — `verbose-error-messages = off` | Internal parse state or stack detail in a 400 response under defaults | High | *(documented — `reference.conf`)* | | P4 | **Strict URI and RFC6265 cookie parsing by default**, rather than lenient normalization that invites smuggling | Two components disagreeing on a URI or cookie under `strict` | High | *(documented — `reference.conf`)* | -| P5 | **Connection and request lifetimes are bounded** — `idle-timeout 60s` closes a connection with no traffic in *either* direction for that long, `request-timeout 20s` bounds handling once a request has been received, `max-connections 1024`, `pipelining-limit 1`. `idle-timeout` is an inactivity timeout and not a receive deadline: a client that sends a byte inside every window keeps the connection alive by design (§9) | A connection with no traffic outliving `idle-timeout`; a received request outliving `request-timeout` with no response; resources held past either bound after the connection is gone | High | *(documented — `reference.conf`, `timeouts.md`)* | -| P6 | **HTTP/2 concurrency and stream churn are bounded** — `max-concurrent-streams = 256` caps streams open at once, and `frame-type-throttle` rate-limits the frames that let a peer cycle through that cap: `RST_STREAM` is charged by default at 100 per second with a burst of 100. Concurrency alone is not a bound on *work*, because a reset frees the slot immediately — this is the HTTP/2 Rapid Reset shape (CVE-2023-44487) | Unbounded stream/state growth on one connection, or unbounded stream churn through a bounded concurrency limit | High | *(documented — `reference.conf`)* | -| P7 | **HTTP/2 header blocks are bounded** — `max-header-list-size = 64 KiB` caps the decompressed header list *and* the accumulated HEADERS + CONTINUATION fragments, so a header block the peer never terminates with `END_HEADERS` cannot grow without bound; over-limit blocks get `GOAWAY(ENHANCE_YOUR_CALM)` rather than being buffered | Unbounded buffering from a CONTINUATION flood or an oversized header list | **Critical** | *(documented — `reference.conf`)* | -| P8 | **Credential comparison is constant-time** where the verifier calls `Credentials.verify` — it compares via `secure_==`, which XOR-accumulates over the full length after a length check, rather than short-circuiting on the first differing byte | Secret recoverable byte-by-byte from response timing against a `verify`-based verifier | High | *(documented — `SecurityDirectives.scala`, `EnhancedByteArray.scala:37`)* | -| P9 | **HTTP/2 streams are isolated from each other's malformed requests**: a request that fails to parse is answered with a `400` on its own stream (`RequestErrorFlow`), and the connection — its HPACK state and every other stream on it — carries on. A connection a proxy multiplexes for many clients is the deployment this protects | One stream's malformed request failing, stalling or desynchronising the connection for the other streams | High | *(documented — `RequestErrorFlow.scala:57-60`)* | +| P5 | **Connection and request lifetimes are bounded** — `idle-timeout 60s` closes a connection with no traffic in *either* direction for that long; `request-timeout 20s` bounds handling once a request has been received; `max-connections 1024`; `pipelining-limit 1`. `idle-timeout` is an inactivity timeout, not a receive deadline (§9) | A connection with no traffic outliving `idle-timeout`; a received request outliving `request-timeout` with no response; resources held past either bound after the connection is gone | High | *(documented — `reference.conf`, `timeouts.md`)* | +| P6 | **HTTP/2 concurrency and stream churn are bounded** — `max-concurrent-streams = 256` caps open streams, and `frame-type-throttle` rate-limits `RST_STREAM` (100/s, burst 100) so a peer cannot cycle through that cap for free (Rapid Reset, CVE-2023-44487) | Unbounded stream/state growth on one connection, or unbounded churn through a bounded concurrency limit | High | *(documented — `reference.conf`)* | +| P7 | **HTTP/2 header blocks are bounded** — `max-header-list-size = 64 KiB` caps the decompressed header list *and* the accumulated HEADERS + CONTINUATION fragments; over-limit blocks get `GOAWAY(ENHANCE_YOUR_CALM)` rather than being buffered | Unbounded buffering from a CONTINUATION flood or an oversized header list | **Critical** | *(documented — `reference.conf`)* | +| P8 | **Credential comparison is constant-time** where the verifier calls `Credentials.verify` — `secure_==` XOR-accumulates over the full length after a length check | Secret recoverable byte-by-byte from response timing against a `verify`-based verifier | High | *(documented — `SecurityDirectives.scala`, `EnhancedByteArray.scala`)* | +| P9 | **HTTP/2 streams are isolated from each other's malformed requests**: a request that fails to parse is answered with a `400` on its own stream, and the connection — its HPACK state and every other stream — carries on. A connection a proxy multiplexes for many clients is what this protects | One stream's malformed request failing, stalling or desynchronising the connection for the other streams | High | *(documented — `RequestErrorFlow.scala`)* | -**P1-P7 and P9 are default-on properties** — a notable contrast with `apache/pekko`, where the strongest controls must be switched on. P8 is the exception: it holds only for a verifier that calls `Credentials.verify`, which is why §10.4 states it as a downstream responsibility. The boundary of the DoS claim, once this model's largest ambiguity, is now fixed by the §14 Q1 content-vs-volume line. +**P1–P7 and P9 are default-on.** P8 holds only for a verifier that calls `Credentials.verify` (§10.4). The DoS boundary is the §14 Q1 content-vs-volume line. -**A note on P9, which the pinned commit made true in two places.** The property was already the engine's design — `RequestErrorFlow` has answered a `BadRequest` on the one stream since the percent-encoding case in [#59](https://github.com/apache/pekko-http/issues/59) — but two paths around it were found and closed during review. A header field that failed to parse used to unwind straight out of the HPACK decoder, before the entry reached the dynamic table and before the rest of the block was read, so the decoder's table fell out of step with the peer's encoder and every later `HEADERS` frame on the connection decoded to the wrong fields; an unknown method was enough to trigger it ([#1252](https://github.com/apache/pekko-http/pull/1252), merged through [#1251](https://github.com/apache/pekko-http/pull/1251)). And a field the HTTP/1.1 header parser rejects — a NUL in a value, an illegal character in a name, a value over `max-header-value-length` — was reported with that parser's own internal exception type, which nothing on the HTTP/2 side caught, so it failed the decompression stage and with it the connection ([#1297](https://github.com/apache/pekko-http/pull/1297)). The same change closed a §8.2.1 conformance gap: a field value carrying CR LF was handed to the HTTP/1.1 line parser, which stopped at the first CRLF it met and accepted the value silently truncated; a name or value carrying CR, LF or NUL is now a `400`. P9 as stated is a claim about all three paths. *(maintainer)* +**How §8 was verified.** P1, P2 and P9 were first asserted from configuration and design, then checked against the code; each had defects, all fixed at or before the pinned commit. P1: `max-chunk-count` reported the limit without stopping the parse ([#1220](https://github.com/apache/pekko-http/pull/1220)); repeated `Connection` headers escaped `max-header-count` ([#1255](https://github.com/apache/pekko-http/pull/1255)); HTTP/2 connection-level buffer accounting leaked on discarded streams until the connection window drained and every stream stalled ([#1259](https://github.com/apache/pekko-http/pull/1259), [#1281](https://github.com/apache/pekko-http/pull/1281)); nothing bounded multipart part count ([#1266](https://github.com/apache/pekko-http/pull/1266)). P2: the CR/LF guard missed chunked trailers and extensions ([#1256](https://github.com/apache/pekko-http/pull/1256)), NUL ([#1260](https://github.com/apache/pekko-http/pull/1260)) and the HTTP/2 header path ([#1258](https://github.com/apache/pekko-http/pull/1258)). P9: a header that failed to parse desynchronised the HPACK dynamic table for every later frame ([#1252](https://github.com/apache/pekko-http/pull/1252)), and a field the HTTP/1.1 parser rejected failed the connection instead of the stream ([#1297](https://github.com/apache/pekko-http/pull/1297)). The properties as stated are claims about the merged code, not about the settings. *(maintainer)* -**A note on how P1 came to be stated this precisely.** P1 has the same history as P2, and for the same reason: the draft asserted it from the §5a table, which records what each limit is *supposed* to bound. Three of those bounds did not hold as written, all fixed at the pinned commit. `max-chunk-count` was tested without an `else` branch, so the `StateResult` of `failEntityStream` was discarded and the parser trampolined into the next chunk — reaching the limit emitted an error per remaining chunk in the buffer instead of stopping the parse ([#1220](https://github.com/apache/pekko-http/pull/1220)). `max-header-count` was not incremented on the branch that merges repeated `Connection` headers into one accumulated header, so a message could repeat that header without ever reaching the limit ([#1255](https://github.com/apache/pekko-http/pull/1255)). And on the HTTP/2 side the connection-level buffer accounting only ever went up: `totalBufferedData` was not decremented when a buffered stream was discarded on `RST_STREAM` or on downstream cancellation, so a peer that sent data it never let the handler read ratcheted the counter up permanently — once the leak passed half of `incoming-connection-level-buffer-size` the server stopped replenishing the connection window, it drained to zero, and every stream on that connection stalled ([#1259](https://github.com/apache/pekko-http/pull/1259); a third discard path, the stream-level `shutdown()` that a `FLOW_CONTROL_ERROR` on one stream takes while the connection carries on, leaked the same way and was closed by [#1281](https://github.com/apache/pekko-http/pull/1281)). A fourth bound was missing outright rather than broken: nothing limited how many parts a multipart entity may carry, so a body at `max-content-length` packed with minimal parts ran to well over a hundred thousand of them, each materialised with headers and an entity of its own — `max-part-count` now bounds it ([#1266](https://github.com/apache/pekko-http/pull/1266)). P1 as stated above is a claim about what the parser and the frame handler do when a bound is reached, not about what the table says the bound is for. *(maintainer)* - -**One bound is missing at the pinned commit, and P1 is stated with that gap open.** The HTTP/2 frame parser reads a frame's 24-bit length field and then takes that many bytes with no upper limit (`Http2FrameParsing.scala:193-200`), so a peer can make it buffer up to 16 MiB - 1 for a single frame. That happens *before* HPACK decoding and before the entity is assembled, so none of the three HTTP/2 buffer bounds in §5a constrain it, and the cost is multiplied by the number of connections. A `max-frame-size` setting is in flight ([#1264](https://github.com/apache/pekko-http/pull/1264)); until it lands, a report that an oversized frame is buffered is `VALID` against P1 rather than a §12 model gap — the bound is claimed here and does not yet exist. *(maintainer)* - -**A note on how P2 came to be stated this precisely.** The draft asserted P2 from the two `reference.conf` processing-mode defaults alone, without walking the path those settings are supposed to govern. Walking it found the guard was not where the claim assumed: it lived in one `~~(HttpHeader)` overload, so chunked-response **trailers** and **chunk extensions** were rendered around it ([#1256](https://github.com/apache/pekko-http/pull/1256)), it tested for CR and LF but not **NUL** ([#1260](https://github.com/apache/pekko-http/pull/1260)), and the **HTTP/2** header path had no equivalent check at all, so a mitigation an application relied on under HTTP/1.1 disappeared on upgrade ([#1258](https://github.com/apache/pekko-http/pull/1258)). All three are fixed at the pinned commit, and the rule now lives in one predicate (`Rendering.isIllegalHeaderChar`) rather than in four copies. P2 as stated above is a claim about the merged enforcement path, not about the two settings. *(maintainer)* +**One P1 gap is open at the pinned commit.** The HTTP/2 frame parser buffers a frame of any declared length — up to 16 MiB − 1 — before HPACK decoding and before any §5a buffer bound applies, multiplied by connection count (`Http2FrameParsing.scala`). A `max-frame-size` setting is in flight ([#1264](https://github.com/apache/pekko-http/pull/1264)); until it lands, an oversized-frame report is `VALID` against P1, not a model gap. *(maintainer)* --- ## §9 Security properties Pekko HTTP does **not** provide -- **No claim of complete DoS resistance.** The documented wording is *"behaves pretty well under most known Denial of Service attacks"*, immediately followed by a recommendation to front it with a load balancer or enterprise routing solution *(documented — `security.md`)*. Per §14 Q1 this disclaimer is **scoped to volume**: Pekko HTTP does not claim to withstand floods, but it *does* claim that one in-limits request cannot provoke disproportionate work — that part is P1, and a violation is `VALID`. *(maintainer — §14 Q1)* -- **No edge hardening.** Rate limiting, IP reputation, request scrubbing, connection-count throttling beyond `max-connections`, slow-loris mitigation beyond `idle-timeout` — none are provided, and none are planned. *(maintainer — §14 Q1)* -- **No authentication or authorization.** The security directives are plumbing; the credential check is the application's function. *(maintainer — §14 Q7)* -- **No CSRF protection.** No token issuance or verification is provided. *(maintainer — §14 Q7)* +- **No claim of complete DoS resistance.** *"Behaves pretty well under most known Denial of Service attacks"* *(documented — `security.md`)* is scoped to **volume** (§14 Q1): Pekko HTTP does not claim to withstand floods, but does claim that one in-limits request cannot provoke disproportionate work — that is P1, and a violation is `VALID`. +- **No edge hardening.** Rate limiting, IP reputation, request scrubbing, connection throttling beyond `max-connections`, slow-loris mitigation beyond `idle-timeout` — none provided, none planned. *(maintainer — §14 Q1)* +- **No authentication or authorization.** The security directives are plumbing. *(maintainer — §14 Q7)* +- **No CSRF protection.** *(maintainer — §14 Q7)* - **No output encoding / XSS defence.** Pekko HTTP renders what the application marshals. *(maintainer — §14 Q7)* -- **No trusted client-IP derivation from headers.** `extractClientIP` surfaces `X-Forwarded-For` / `X-Real-Ip` without validating them, and Pekko HTTP has no trusted-proxy chain configuration. `extractDirectClientIP` is the trustworthy accessor, but it yields the last proxy rather than the client. See §5 and §14 Q4. +- **No trusted client-IP derivation from headers.** `extractClientIP` surfaces `X-Forwarded-For` / `X-Real-Ip` unvalidated; there is no trusted-proxy chain configuration. `extractDirectClientIP` is trustworthy but yields the last proxy, not the client (§14 Q4). ### False friends -- **The security directives are not a security *system*.** `authenticateBasic` and `authenticateOAuth2` route credentials to an application-supplied verifier; they impose no password policy, no rate limiting and no lockout. They *do* supply a constant-time comparison (§8 P8) — but only to a verifier that calls `Credentials.verify`; one that pattern-matches the secret out and uses `==` gets none of it. -- **`allowed-origins = "*"` does not mean "no credentials are exposed".** Combined with the shipped `allow-credentials = yes`, it echoes the caller's `Origin` (§5a). +- **The security directives are not a security *system*.** `authenticateBasic` and `authenticateOAuth2` route credentials to an application-supplied verifier; no password policy, rate limiting or lockout. They supply a constant-time comparison (P8) only to a verifier that calls `Credentials.verify`. +- **`allowed-origins = "*"` does not mean "no credentials are exposed".** With the shipped `allow-credentials = yes` it echoes the caller's `Origin` (§5a). - **`remote-address-attribute` is not the client IP behind a proxy.** It is the socket peer. - **`max-content-length` is not a global memory bound.** It bounds one entity; concurrent connections multiply it. -- **`verbose-error-messages = off` is not a log-hygiene setting.** It governs what reaches the *client*. What reaches the *server log* is `error-logging-verbosity`, which ships `full`: a request whose target fails to parse has that target written to the log at warning level, under the parser's caret line marking the failing byte (`UriParser.scala:117-120`, `parsing/package.scala:59-61`). Attacker-controlled bytes in the log at an alerting level are a log-injection and log-flooding consideration the operator owns (§10.9). *(documented — `reference.conf`, `ErrorInfo.scala`)* +- **`verbose-error-messages = off` is not a log-hygiene setting.** It governs what reaches the *client*. What reaches the *server log* is `error-logging-verbosity`, which ships `full`: a request whose target fails to parse has that target written to the log at warning level (`UriParser.fail`, `logParsingError`). Attacker-chosen bytes in the log at an alerting level are a log-injection and log-flooding consideration the operator owns (§10.9). *(documented — `reference.conf`, `ErrorInfo.scala`)* - **A rejection is not a failure.** The routing DSL's rejection mechanism is control flow, not a security control — an unhandled rejection can fall through to a different route. ### Well-known attack classes left to the caller -- **Request smuggling / desync** between a fronting proxy and Pekko HTTP — inherently a two-party property; strict parsing (P4) helps but cannot settle it alone. What Pekko HTTP does own is its own framing decision, and a case where it framed a message by a different rule than a conformant upstream would is a defect here rather than a two-party stalemate: a `Transfer-Encoding` whose value failed to parse was degraded to a `RawHeader`, never reached the modelled `Transfer-Encoding` arm of the header loop, and so left `isChunked` false — with a `Content-Length` also present the message was framed by length, where an upstream that parsed the value framed it as chunked. It is now rejected ([#1267](https://github.com/apache/pekko-http/pull/1267)); the clear cases — `chunked` with a `Content-Length`, an unsupported coding, multiple entries — were already rejected. A second case of the same shape sat in the chunk-size parser: whitespace tolerated *after* the size digits — illegal, but seen in the wild — was also tolerated *between* them, and hex accumulation continued past it, so `5 0` parsed as `0x50`, 80 bytes, where a proxy that stops at the space reads 5. Interior whitespace is now rejected and trailing whitespace stays tolerated ([#1257](https://github.com/apache/pekko-http/pull/1257)). -- **Slow-loris and connection exhaustion** — `idle-timeout` and `max-connections` (P5) bound what one connection holds and how many are accepted, but exhausting those bounds by volume is disclaimed (§14 Q1). What `idle-timeout` bounds is *inactivity*, in either direction: the documentation states that a client sending bytes at intervals shorter than the timeout keeps the connection alive, that the setting is not a request-receive deadline, and that a hard receive deadline needs a reverse proxy or an application-level entity timeout *(documented — `reference.conf`, `timeouts.md`)*. A trickling client is therefore `BY-DESIGN: property-disclaimed`, not an evasion. The in-scope version is a connection holding resources with **no** traffic that is not closed when the timeout elapses — or a bound whose own cleanup leaks, as the request-timeout path did: a connection that went away with requests still open left one scheduled task per request alive for the full `request-timeout`, keeping the request and the stage reachable, fixed in [#1284](https://github.com/apache/pekko-http/pull/1284). -- **Decompression bombs** are *not* wholly left to the caller. Where the application decodes through the routing DSL, `decodeRequest` / `decodeRequestWith` bound the result on both axes: the decoder's per-chunk output is set to `decode-max-bytes-per-chunk` (`1m`) and the decoded stream is wrapped in `withSizeLimit(decode-max-size)` (`8m`), so a small gzip body that expands past the limit fails the stream instead of filling the heap (`CodingDirectives.scala:93-102`). Every `decodeRequest*` overload funnels through that one directive, so the bound is not something the caller can forget to apply. What is left to the caller is decoding done *outside* those directives — calling `Coders.Gzip.decodeMessage` on a request, or decoding a response on the client side, gets no limit — and the choice to raise `decode-max-size`, which is read with `getPossiblyInfiniteBytes` and so can be set to infinite (`RoutingSettingsImpl.scala:44`). +- **Request smuggling / desync** between a fronting proxy and Pekko HTTP is inherently two-party; strict parsing (P4) helps but cannot settle it alone. What Pekko HTTP does own is its own framing decision: framing a message by a different rule than a conformant upstream would is a defect here, not a stalemate. An unparseable `Transfer-Encoding` framed by `Content-Length` ([#1267](https://github.com/apache/pekko-http/pull/1267)) and whitespace tolerated *inside* a chunk size ([#1257](https://github.com/apache/pekko-http/pull/1257)) were both such cases; both are now rejected. +- **Slow-loris and connection exhaustion.** `idle-timeout` bounds *inactivity* in either direction: a client sending a byte inside every window keeps the connection alive by design, and a hard receive deadline needs a reverse proxy or an application-level entity timeout *(documented — `reference.conf`, `timeouts.md`)*. A trickling client is `BY-DESIGN: property-disclaimed`; exhausting the bounds by volume is disclaimed (§14 Q1). In scope: a connection holding resources with **no** traffic that outlives the timeout, or a bound whose own cleanup leaks — as the request-timeout path did, keeping one scheduled task per open request alive after the connection was gone ([#1284](https://github.com/apache/pekko-http/pull/1284)). +- **Decompression bombs** are bounded where decoding goes through `decodeRequest` / `decodeRequestWith`: per-chunk output by `decode-max-bytes-per-chunk`, decoded size by `decode-max-size` (§5a, `CodingDirectives.scala`). Left to the caller: decoding outside those directives — `Coders.Gzip.decodeMessage`, or client-side response decoding — and raising `decode-max-size`, which may be set to `infinite`. - **SSRF** via the client API, where the application takes a URL from a request. -- **Path traversal** is left to the caller in some of the file-serving directives and not others, and the split is worth stating precisely (§14 Q3). `getFromDirectory`, `listDirectoryContents` and the `getFromBrowseableDirector*` pair built on them run the unmatched path through `safeDirectoryChildPath`, which applies both a segment filter and canonical containment against the served root — containment is claimed there, so a genuine escape is `VALID` per §5b.4. `getFromResourceDirectory` applies the segment filter alone (`safeJoinPaths`), which is coherent because a class-loader resource name is not a file-system path and has no canonical form to compare, so its guarantee is that no `..` or separator survives, not that the result is below the base. `getFromFile` and `getFromResource` claim nothing at all: they serve a name the application supplies, with no base directory to be contained by, and handing either one request input is a §11 misuse rather than a library defect. Review of this model found exactly such a defect: the containment check compared canonical paths as **strings**, so a symbolic link resolving into a sibling directory whose name shares the served root as a string prefix (`/var/www` vs `/var/www-private`) escaped it — fixed by [#1218](https://github.com/apache/pekko-http/pull/1218), which compares path elements (§14 Q3). One platform limit is documented at the function: containment rests on `File.getCanonicalPath`, which on Windows does not resolve NTFS symbolic links or junctions, so the link-escape class stays open there. What remains the caller's is the surrounding choice: which root is served, and whether the tree under it contains symlinks at all — a choice that carries the containment on Windows. +- **Path traversal** — three tiers (§14 Q3). `getFromDirectory`, `listDirectoryContents` and the `getFromBrowseableDirector*` pair run the unmatched path through `safeDirectoryChildPath`: segment filter plus canonical containment against the served root. Containment is claimed there, so an escape is `VALID` — one was found and fixed, a symlink into a sibling directory whose name shared the root as a string prefix ([#1218](https://github.com/apache/pekko-http/pull/1218)). `getFromResourceDirectory` applies the segment filter only, since a class-loader resource name has no canonical form. `getFromFile` and `getFromResource` filter nothing; handing either request input is a §11 misuse. Residual: on Windows `File.getCanonicalPath` does not resolve NTFS symlinks or junctions, so a link out of the root is not detected there — serving a link-free tree is the operator's. - **XXE** in XML marshallers — a property of the underlying parser. --- @@ -274,43 +261,43 @@ Pekko HTTP therefore takes the following position *(maintainer)*: 1. **Put an enterprise-grade proxy or load balancer in front** of an internet-facing service *(documented — `security.md`)*. 2. **Do not raise the §5a limits without understanding the memory cost** — each is multiplied by concurrent connections. -3. **If CORS is enabled, set `allowed-origins` explicitly.** Do not ship the `"*"` + `allow-credentials = yes` combination to a credentialed API (§5a). -4. **Compare credentials with `Credentials.verify`**, which is constant-time (§8 P8) — not with `==` on the secret, and not via `provideVerify` unless the supplied verifier is itself constant-time. -5. **Use `extractDirectClientIP`, not `extractClientIP`, for access control, rate limiting or audit logging.** The latter reads `X-Forwarded-For` / `X-Real-Ip`, which the client controls unless a trusted proxy overwrites them; it requires `remote-address-attribute = on` to fall back usefully. Derive identity from `X-Forwarded-For` only where a trusted proxy sets it and you validate the chain yourself. +3. **If CORS is enabled, set `allowed-origins` explicitly.** Do not ship `"*"` + `allow-credentials = yes` to a credentialed API (§5a). +4. **Compare credentials with `Credentials.verify`** (P8) — not `==` on the secret, and not `provideVerify` unless the supplied verifier is itself constant-time. +5. **Use `extractDirectClientIP`, not `extractClientIP`, for access control, rate limiting or audit logging.** Derive identity from `X-Forwarded-For` only where a trusted proxy sets it and you validate the chain yourself. 6. **Validate and canonicalize any request-derived path** before passing it to a file-serving directive. 7. **Treat client-API responses from untrusted upstreams as untrusted input.** 8. **Consider `server-header = ""`** if product/version disclosure matters to your threat model. -9. **Consider `error-logging-verbosity = simple`** where logs are shipped or alerted on: the shipped `full` writes the failing request target into the log at warning level (§9). +9. **Consider `error-logging-verbosity = simple`** where logs are shipped or alerted on (§9). --- ## §11 Known misuse patterns -- **Exposing a Pekko HTTP service directly to the internet** with no fronting proxy, contrary to the documented recommendation. +- **Exposing a Pekko HTTP service directly to the internet** with no fronting proxy. - **Enabling `cors()` and leaving `allowed-origins = "*"`** on an API that uses cookies or bearer tokens. -- **Reaching for `extractClientIP`** — the more discoverable name — for rate limiting, audit logging or access control, where `extractDirectClientIP` is the one that cannot be chosen by the caller. +- **Reaching for `extractClientIP`** for rate limiting, audit logging or access control, where `extractDirectClientIP` is the one the caller cannot choose. - **Raising `max-content-length` to `infinite`** to accept large uploads, without a concurrency bound. -- **Comparing credentials with `==`** inside an `authenticateBasic` verifier — or reaching for `provideVerify` with a non-constant-time verifier — instead of `Credentials.verify`. -- **Passing a request path segment straight to `getFromFile` or `getFromResource`.** Neither filters what it is handed. `getFromResource` is the easier of the two to get wrong: it looks like the resource-side sibling of `getFromResourceDirectory`, but where that directive routes the request path through `safeJoinPaths` before delegating, `getFromResource` passes the name to `classLoader.getResource` with only a trailing-slash check to keep directory resources unserved (`FileAndResourceDirectives.scala:109-129`), so `getFromResource(s"public/$name")` against a directory-backed class loader can be walked out of the intended prefix and into `application.conf` or `logback.xml`. -- **Turning `verbose-error-messages = on`** in production to aid debugging. -- **Building a `Raw-Request-URI` header from request input.** The header is an escape hatch that renders the request target verbatim — into the HTTP/1.1 request line with no CR/LF guard (`HttpRequestRendererFactory.scala:52-53`), and since [#1280](https://github.com/apache/pekko-http/pull/1280) as the HTTP/2 `:path`, where the HPACK guard drops a field carrying CR, LF or NUL and so yields a malformed request rather than an injection. Under §6 the header is application-supplied and trusted, which is exactly why it must not carry client bytes. -- **Echoing `IllegalRequestContext.rawRequestTarget` from a custom `ParsingErrorHandler`** into the response or the log without escaping it. The class documents the field as unvalidated, attacker-controlled input (`ParsingErrorHandler.scala:33-35`); the default handler never reads it. +- **Comparing credentials with `==`** inside an `authenticateBasic` verifier, or `provideVerify` with a non-constant-time verifier. +- **Passing a request path segment straight to `getFromFile` or `getFromResource`.** Neither filters what it is handed. `getFromResource` looks like the sibling of `getFromResourceDirectory` but skips the segment filter that directive applies, so `getFromResource(s"public/$name")` against a directory-backed class loader can be walked into `application.conf` or `logback.xml`. +- **Turning `verbose-error-messages = on`** in production. +- **Building a `Raw-Request-URI` header from request input.** It renders the request target verbatim — into the HTTP/1.1 request line, and since [#1280](https://github.com/apache/pekko-http/pull/1280) as the HTTP/2 `:path`. Under §6 it is application-supplied and trusted, which is exactly why it must not carry client bytes. +- **Echoing `IllegalRequestContext.rawRequestTarget` from a custom `ParsingErrorHandler`** into the response or log without escaping it. The class documents it as attacker-controlled; the default handler never reads it. --- ## §11a Known non-findings (recurring false positives) -- **"`Server` header discloses the product and version."** Documented default, configurable via `server-header` (§5a). Not a vulnerability under this model; a request to blank it by default is `BY-DESIGN: default-configuration` per §5b. +- **"`Server` header discloses the product and version."** Documented default, configurable via `server-header`. `BY-DESIGN: default-configuration` per §5b. - **"No authentication on routes."** Authentication is the application's responsibility (§9). A scan of this library cannot conclude a route is unauthenticated. - **"Request exceeding `max-uri-length` / `max-header-count` is rejected."** That is P1 working. -- **"N concurrent connections / requests exhaust CPU, memory or sockets."** Volume-based resource exhaustion is `BY-DESIGN: property-disclaimed` per §14 Q1 — defence belongs to the fronting proxy (§10.1). Reports must show *one* in-limits request doing disproportionate work, not many requests doing proportionate work. A load-generator result is not a finding. -- **"`extractClientIP` trusts a client-supplied header."** By design and documented at the directive, with `extractDirectClientIP` provided as the trustworthy alternative (§14 Q4). `BY-DESIGN: property-disclaimed`. A report that `extractDirectClientIP` can be influenced by a header *is* in scope. -- **"CORS allows any origin."** Reflects the shipped default and requires the application to have opted into `cors()`. A request to change the default is `BY-DESIGN: default-configuration` per §5b; a misconfigured deployment is a finding against the *application*, not the library. -- **"`allow-credentials = yes` with `allowed-origins = "*"` sends `Access-Control-Allow-Origin: *` with credentials."** It does not — the literal `*` is sent only when `allowCredentials` is false, otherwise the request `Origin` is echoed (`CorsSettingsImpl.scala:63`, covered by `CorsDirectivesSpec`). Reports asserting the literal `*`-with-credentials combination are factually wrong. -- **"Credential comparison is vulnerable to a timing attack."** Check which comparator the report exercises: `Credentials.verify` is constant-time (§8 P8), so the claim is wrong against it; against an application's own `provideVerify` comparator it is a finding in that application, not this library. +- **"N concurrent connections / requests exhaust CPU, memory or sockets."** Volume-based exhaustion is `BY-DESIGN: property-disclaimed` per §14 Q1 — defence belongs to the fronting proxy (§10.1). A report must show *one* in-limits request doing disproportionate work. A load-generator result is not a finding. +- **"`extractClientIP` trusts a client-supplied header."** By design and documented at the directive, with `extractDirectClientIP` as the trustworthy alternative (§14 Q4). `BY-DESIGN: property-disclaimed`. A report that `extractDirectClientIP` can be influenced by a header *is* in scope. +- **"CORS allows any origin."** The shipped default, reachable only where the application opted into `cors()`. `BY-DESIGN: default-configuration` per §5b; a misconfigured deployment is a finding against the *application*. +- **"`allow-credentials = yes` with `allowed-origins = "*"` sends `Access-Control-Allow-Origin: *` with credentials."** It does not — the literal `*` is sent only when credentials are not allowed; otherwise the request `Origin` is echoed (`CorsSettingsImpl.scala`, covered by `CorsDirectivesSpec`). Factually wrong. +- **"Credential comparison is vulnerable to a timing attack."** Wrong against `Credentials.verify` (P8); against an application's own `provideVerify` comparator it is a finding in that application. - **Findings in `*-tests`, `http-testkit*`, `http-bench-jmh`, `http-scalafix`, `docs`** — `OUT-OF-MODEL: unsupported-component` per §3. -- **"The process registers JVM shutdown hooks."** Pekko HTTP registers none (§5, §14 Q9). The hooks are `CoordinatedShutdown`'s and, with remoting enabled, Artery's — they belong to `apache/pekko`'s model. `OUT-OF-MODEL: unsupported-component`. -- **Findings in the actor or stream layer** — belongs to `apache/pekko`'s model, not this one. +- **"The process registers JVM shutdown hooks."** Pekko HTTP registers none (§5). The hooks are `CoordinatedShutdown`'s and, with remoting, Artery's — `apache/pekko`'s model. `OUT-OF-MODEL: unsupported-component`. +- **Findings in the actor or stream layer** — `apache/pekko`'s model, not this one. --- @@ -321,7 +308,7 @@ Pekko HTTP therefore takes the following position *(maintainer)*: - Taking on any authentication, authorization, or rate-limiting responsibility currently disclaimed in §9. - A change to the documented "do not expose directly" posture in `security.md`. - Promotion of a §3 module into the supported surface. -- A change to the §5 process-behaviour claims. One near-miss is on record: [#1217](https://github.com/apache/pekko-http/pull/1217) originally replaced the per-upload `deleteOnExit` with a raw JVM shutdown hook registered by Pekko HTTP itself, which would have invalidated the "registers no shutdown hook" claim in §5 and the matching §11a non-finding; it was reworked in review to register the cleanup as a `CoordinatedShutdown` task on the actor system instead, and merged in that form, so the claims stand — re-checked against the merged code at the pinned commit, which contains no `addShutdownHook` outside a comment explaining why one is not used. +- A change to the §5 process-behaviour claims. One near-miss is on record: [#1217](https://github.com/apache/pekko-http/pull/1217) originally registered a JVM shutdown hook from Pekko HTTP itself and was reworked to a `CoordinatedShutdown` task before merging, so the claims stand. - **A report that cannot be routed to exactly one §13 disposition** — evidence of a model gap; revise the model rather than making an ad-hoc call. --- @@ -335,7 +322,7 @@ Pekko HTTP therefore takes the following position *(maintainer)*: | `OUT-OF-MODEL: trusted-input` | Requires control of an input §6 marks trusted (configuration, application-supplied handlers) | §6 | | `OUT-OF-MODEL: adversary-not-in-scope` | Requires in-JVM code execution, or a malicious embedding application | §7 | | `OUT-OF-MODEL: unsupported-component` | Lands in a §3 module, or in the actor/stream layer | §3 | -| `OUT-OF-MODEL: non-default-build` | Only manifests under a non-default §5a setting — including any resource-exhaustion report that needs a limit **raised** from its default. Distinct from `default-configuration`: this is a real defect reachable only off-default, that one is no defect at all | §5a, §14 Q1 | +| `OUT-OF-MODEL: non-default-build` | Only manifests under a non-default §5a setting — including any resource-exhaustion report that needs a limit **raised** from its default. A real defect reachable only off-default; contrast `default-configuration`, which is no defect at all | §5a, §14 Q1 | | `BY-DESIGN: default-configuration` | Asks that a §5a default be changed to a more restrictive value. Not a vulnerability; §5b.3 invites the proposal on the development list | §5b | | `BY-DESIGN: property-disclaimed` | Concerns a §9 property — authentication, CSRF, XSS, edge hardening — or depends on request **volume** rather than request **content** | §9, §14 Q1 | | `KNOWN-NON-FINDING` | Matches a §11a pattern | §11a | @@ -343,54 +330,41 @@ Pekko HTTP therefore takes the following position *(maintainer)*: --- -## §14 Open questions for the maintainers +## §14 Maintainer rulings -Each states a **proposed answer**; confirming or correcting is enough. +Questions the draft put to the maintainers, with the ruling each received. All are settled model *(maintainer)*. -**Q1 — Where exactly is the DoS line? — ANSWERED.** *(maintainer)* -The proposed split was accepted as written. The **DoS line** is now settled model, restated here as the canonical form and applied throughout §4, §5a, §8, §9, §11a and §13: +**Q1 — Where is the DoS line?** Content vs. volume: -> A **single request within every §5a documented limit** that provokes super-linear CPU or memory is `VALID` — this is the P1 violation symptom. +> A **single request within every §5a documented limit** that provokes super-linear CPU or memory is `VALID` — the P1 violation symptom. > A finding that requires a §5a limit to be **raised** from its shipped default is `OUT-OF-MODEL: non-default-build`. > A finding that depends on request **volume** rather than request **content** — connection floods, slow-loris at scale, aggregate bandwidth — is `BY-DESIGN: property-disclaimed`. Volume defence is the fronting proxy's job (§10.1). -The operative test is *content vs. volume*: one well-formed, in-limits request doing disproportionate work is a bug in Pekko HTTP; many requests doing proportionate work is a deployment concern. - -**Q2 — The CORS defaults. ANSWERED *(maintainer)*.** **Answer:** a compatibility default under §5b. The values arrived with the donated `http-cors` code and existing users depend on them; `cors()` is opt-in, and an application that enables it is expected to configure it, with §10.3 stating what to set. A report that the shipped default should change is `BY-DESIGN: default-configuration` — welcome on the development list, not as a security report. What *is* in scope is the implementation: if `cors()` admits an origin its configuration should have rejected, or emits credentials for one it should not, that is a defect under §5b.4. +**Q2 — The CORS defaults.** A compatibility default under §5b: the values arrived with the donated code and existing users depend on them; `cors()` is opt-in and an application that enables it is expected to configure it (§10.3). Changing the default is `BY-DESIGN: default-configuration`. The *implementation* is in scope: `cors()` admitting an origin its configuration should have rejected, or emitting credentials for one it should not, is a defect under §5b.4. -**Q3 — File-serving directives. ANSWERED *(maintainer)*.** *Resolved from code — and, like Q4 and Q5, a correction: the draft asserted containment held, and reviewing the check found a defect in it.* `safeDirectoryChildPath` claims containment by two measures: a path segment must not be `..` and must not contain `/` or `\\`; and the resolved file's `File.getCanonicalPath` must be prefixed by the base path's. The second measure compared the canonical paths as **strings** (`canonicalFinalPath.startsWith(...)`), and a string prefix is not a path prefix: with `/var/www` served, `/var/www-private/secret` satisfies it without being below the served root. The segment filter cannot catch this — the path only leaves the root when `getCanonicalPath` resolves a symbolic link into such a sibling, and no segment looks suspicious. **Answer:** containment is claimed, so the escape is `VALID` under §5b.4 — this is precisely the "walked out of" class that section names — and it is fixed by [#1218](https://github.com/apache/pekko-http/pull/1218), which compares canonical paths element by element. That fix also settles the draft's residual question, with one platform caveat the fix now states in the function's scaladoc. Where `File.getCanonicalPath` resolves links (POSIX), a symlink resolving out of the served root is *rejected by the containment check*, whatever its target is named, so a working escape is a `VALID` finding rather than an operator responsibility. On Windows, `getCanonicalPath` does not resolve NTFS symbolic links or junctions, so a link out of the root is not detected there — a known residual (the principled follow-up is a `Path.toRealPath`-based check), and on that platform the operator's choice to serve a link-free tree is what carries the containment. #1218 also hardened the same function's failure mode: a decoded path segment that no file-system path may contain (a NUL byte anywhere, characters such as `<` on Windows) is rejected with the traversal warning instead of escaping to the exception handler as a server error. The operator keeps the surrounding choices — which root is served, and whether symlinks exist under it. **The answer is scoped to the directives that call `safeDirectoryChildPath`** — `getFromDirectory` and `listDirectoryContents`, and therefore `getFromBrowseableDirectory`/`getFromBrowseableDirectories`, which are built from the two. `getFromResourceDirectory` calls `safeJoinPaths` only, so it gets the segment filter without the canonical containment check; that is coherent, since a class-loader resource name is not a file-system path and has no canonical form to compare against a root, but it means the containment claim above is not the guarantee it offers. `getFromFile` and `getFromResource` call neither, and passing request input into either remains a §11 misuse rather than a library defect. *(documented — `FileAndResourceDirectives.scala:246-296`; fix in [#1218](https://github.com/apache/pekko-http/pull/1218))* +**Q3 — File-serving directives.** Containment is claimed by `safeDirectoryChildPath` — segment filter (`..`, `/`, `\`) plus canonical containment of the resolved file under the served root — for `getFromDirectory`, `listDirectoryContents` and the `getFromBrowseableDirector*` pair. A genuine escape is `VALID` under §5b.4; review found one (string-prefix comparison of canonical paths, [#1218](https://github.com/apache/pekko-http/pull/1218)). `getFromResourceDirectory` gets the segment filter only. `getFromFile` and `getFromResource` get neither and passing them request input is a §11 misuse. Residual: on Windows `File.getCanonicalPath` does not resolve NTFS symlinks or junctions; serving a link-free tree there is the operator's. *(documented — `FileAndResourceDirectives.scala`)* -**Q4 — `X-Forwarded-For` and client identity. ANSWERED *(maintainer)*.** The disposition is confirmed, but the draft's stated basis for it was wrong and is corrected here. Pekko HTTP **does** parse forwarding headers: `extractClientIP` resolves `X-Forwarded-For` (first address) → `X-Real-Ip` → the `remoteAddress` attribute, in that order (`MiscDirectives.scala:142-145`). What is true is that it never does so *implicitly* — `remote-address-attribute` populates the attribute from the socket peer only, and a route gets header-derived values solely because it called `extractClientIP`. +**Q4 — `X-Forwarded-For` and client identity.** `extractClientIP` resolves `X-Forwarded-For` (first address) → `X-Real-Ip` → the `remoteAddress` attribute, and its scaladoc says the headers are under the client's control unless a trusted proxy overwrites them. `extractDirectClientIP` reads the attribute alone. "`extractClientIP` trusts a spoofable header" is `BY-DESIGN: property-disclaimed`; anything client-controlled reaching `extractDirectClientIP` is `VALID`. *(documented — `MiscDirectives.scala`)* -**Answer:** the spoofability is documented at the directive rather than defended against. `extractClientIP`'s own scaladoc warns that *"the headers are under the control of the client unless a trusted proxy in front of this server overwrites them"* and directs the reader to `extractDirectClientIP` *"where the address must not be chosen by the client, for example for access control or rate limiting"* — a directive added for exactly this purpose (#1219, `@since 2.0.0`), which reads the attribute alone and ignores headers. So "`extractClientIP` trusts a spoofable header" is `BY-DESIGN: property-disclaimed`: the library offers both a convenient and a trustworthy accessor, documents which is which, and leaves the choice to the application. A defect in `extractDirectClientIP` — anything client-controlled reaching it — would be `VALID`. *(documented — `MiscDirectives.scala:39-64`)* +**Q5 — Constant-time credential comparison.** `Credentials.Provided.verify` compares via constant-time `secure_==` — recorded as P8. A timing finding against `verify` is `VALID`; one against an application's own `provideVerify` comparator is `BY-DESIGN: property-disclaimed` — handing the raw secret to application code waives the guarantee (§10.4). -**Q5 — Constant-time credential comparison. ANSWERED *(maintainer)*.** *Resolved from code — this document's earlier draft had it backwards.* `Credentials.Provided.verify` does compare, via `secure_==` (`EnhancedByteArray.scala:37`), which is constant-time; the library therefore **does** provide the guarantee, recorded as §8 P8. It is conditional on the verifier calling `verify` — `provideVerify` hands the raw secret to application code and waives it. **Answer *(maintainer)*:** the split is confirmed. A timing finding against `verify` — anything that makes the comparison data-dependent — is `VALID` at §8 P8's severity. One against an application's own `provideVerify` comparator is `BY-DESIGN: property-disclaimed`: passing the raw secret to application code waives the guarantee by construction, and §10.4 states the responsibility. +**Q6 — Module in/out split.** The §2 table is the maintainers' own. `http-caching` is **in** — cache-key confusion is a real class. `http-scalafix`, the test kits, `http-bench-jmh`, `docs`, `project`, `scripts` and `legal` are **out**: `OUT-OF-MODEL: unsupported-component`. -**Q6 — Module in/out split (§2 table). ANSWERED *(maintainer)*.** **Answer:** the split shown in §2 is confirmed as the maintainers' own, not merely the ASF Security team's proposal. `http-caching` is **in** model — cache-key confusion is a real class and the directive ships as part of the supported surface. `http-scalafix` is correctly **out**, along with the test kits, `http-bench-jmh`, `docs`, `project`, `scripts` and `legal`: a finding in any of them is `OUT-OF-MODEL: unsupported-component` per §3. +**Q7 — Non-goals.** Confirmed as stated in §3, §7 and §9: no authentication system, authorization policy, CSRF protection or XSS/output encoding (`BY-DESIGN: property-disclaimed`). Out of the adversary model: in-JVM code execution, and a malicious embedding application. In: the remote client, a malicious upstream server behind the client API, a cross-origin attacker where `cors()` is enabled. -**Q7 — The §3/§7/§9 non-goals. ANSWERED *(maintainer)*.** **Answer:** the split is confirmed as stated. Pekko HTTP provides **no** authentication system, authorization policy, CSRF protection or XSS/output encoding — reports against those are `BY-DESIGN: property-disclaimed` per §9. **Out** of the adversary model: attackers with code execution in the embedding JVM, and a malicious embedding application (a route that deliberately leaks is an application bug). **In**: the remote HTTP client (primary), a malicious upstream server where the application drives the client API against an untrusted endpoint, and a cross-origin web attacker where the application has enabled `cors()`. This closes the §3, §7 and §9 non-goals as maintainer-stated rather than inferred. +**Q8 — TLS.** Where Pekko HTTP terminates TLS via `HttpsConnectionContext`, protocol and cipher selection come from the JSSE context the application supplies; nothing is pinned or overridden. "Weak cipher accepted" is `OUT-OF-MODEL: trusted-input`. A defect in how Pekko HTTP *drives* the context — failing to apply a supplied restriction, continuing after a handshake failure — is `VALID`. In-process termination is supported; fronting is what the docs recommend. -**Q8 — TLS. ANSWERED *(maintainer)*.** **Answer:** confirmed. Where Pekko HTTP terminates TLS via `HttpsConnectionContext`, protocol and cipher selection come from the JSSE context the application supplies; Pekko HTTP neither pins a cipher suite nor overrides the JDK's defaults. "Weak cipher accepted" is therefore a deployment finding against that context or the JDK, not a library one — `OUT-OF-MODEL: trusted-input` per §6, which marks configuration as operator-supplied. A defect in how Pekko HTTP *drives* the context — failing to apply a supplied restriction, or continuing after a handshake failure — would be `VALID`. +**Q9 — The §5 negative claims.** Verified by source scan, with one correction: the draft said Pekko HTTP writes no files, and the upload directives do — §5 states the accurate boundary. Pekko HTTP registers no shutdown hook of its own; the `ActorSystem`'s hooks belong to `apache/pekko`'s model. A report that Pekko HTTP installs a hook is factually wrong (§11a). -On the second half: **in-process termination is supported**, and `HttpsConnectionContext` is a first-class API. It is not, however, the posture the documentation steers production deployments toward — §4's quoted recommendation to front the service with an enterprise-grade routing solution or load balancer applies to TLS as much as to volume defence (§14 Q1), and in such a deployment termination is commonly the proxy's job. Both are supported; the fronted one is what the docs recommend. - -**Q9 — The negative claims in §5. ANSWERED *(maintainer)*, with one correction.** *Resolved from code, then ruled on.* Scanned the main sources of `http-core`, `http`, `parsing`, `http-caching` and `http-cors`: no `addShutdownHook`, no `ProcessBuilder`/`Runtime.exec`, no `Signal`/`SignalHandler`, no `System.setProperty`/`Security.*`/`setDefault`, and no bind outside the public `Http().bind*` entry points. **The file-writing claim did not survive review:** the scan missed `FileUploadDirectives.scala:178`, where `fileUploadAll` creates temp files with `Files.createTempFile` + `deleteOnExit` and `storeUploadedFile(s)` streams entity bytes to an application-chosen destination. §5 now states the accurate boundary — writes happen only through upload directives the application installs. The remaining claims hold, and §5 cites the scan rather than asserting them. - -**Answer:** state the boundary here rather than deferring it. The `ActorSystem`'s shutdown hooks are worth **highlighting** in §5 so an integrator is not surprised by them, but the claim this document makes and stands behind is that **Pekko HTTP registers none of its own**. A report that Pekko HTTP installs a shutdown hook is factually wrong (§11a); one about the hooks `CoordinatedShutdown` or Artery register belongs to `apache/pekko`'s model, and is `OUT-OF-MODEL: unsupported-component` here. - -**Q10 — Coexistence. ANSWERED *(maintainer)*.** Following the split adopted in [`apache/pekko#3478`](https://github.com/apache/pekko/pull/3478). Three documents carry security information, each canonical for one thing: +**Q10 — Coexistence of the security documents.** Following [`apache/pekko#3478`](https://github.com/apache/pekko/pull/3478): | Document | Canonical for | Reached by | | --- | --- | --- | -| [`SECURITY.md`](SECURITY.md) | **The reporting policy.** The strongest and canonical statement of how to report and what the project undertakes | Anyone arriving via the repository, and every other document | +| [`SECURITY.md`](SECURITY.md) | **The reporting policy** | Anyone arriving via the repository, and every other document | | `THREAT_MODEL.md` (this document) | **Scope** — what is and is not a vulnerability, and how a report is triaged | Reporters, triagers, scanning tools | | `docs/src/main/paradox/security.md` | Security announcements, and the documentation-site index of security material | Readers of the documentation site | -Every other document **links** to `SECURITY.md` for the reporting policy and to this document for scope, rather than restating either. A change to the policy is therefore made in one place. - -**Note the correction to the draft's proposal.** It suggested that `security.md`'s "Security model" section be reduced to a pointer here. It is *not*, and must not be: §4 quotes that section verbatim as the documented source of the "should not be exposed to the public internet directly" posture, and §15 back-maps four separate claims to it. Reducing it to a pointer would delete the evidence this document is built on and leave §4 citing a redirect. The section stays; `security.md` gains links to `SECURITY.md` and to this file, matching the minimal change made in `apache/pekko#3478`. - -**One statement is referred, not migrated.** `security.md` carries an upstream-coordination sentence that `SECURITY.md` does not: *"Ideally, any issues affecting Apache Pekko and Akka should be reported to Apache team first. We will share the report with the Lightbend Akka team."* Per the same ruling, a reporting statement living outside `SECURITY.md` is referred to the maintainers for a decision — promoted verbatim or dropped, never silently moved. It is left untouched in `security.md` pending that decision. (`apache/pekko` resolved its equivalent by carrying a general sentence in `SECURITY.md`: *"The Pekko PMC will coordinate responsible disclosure with affected upstream maintainers where needed."*) +Each links to the others rather than restating them. `security.md`'s "Security model" section stays as the documented source §4 quotes. **One statement is referred:** `security.md`'s upstream-coordination sentence (*"any issues affecting Apache Pekko and Akka should be reported to Apache team first…"*) is a reporting statement outside `SECURITY.md`; it is left in place pending a maintainer decision to promote it verbatim or drop it. --- @@ -399,7 +373,7 @@ Every other document **links** to `SECURITY.md` for the reporting policy and to | Existing statement | Source | Lands in | | --- | --- | --- | | Applications should not be exposed to the public internet directly | `security.md` | §3, §4, §10.1 | -| Behaves "pretty well" under most known DoS attacks — scoped to *volume* by the §14 Q1 ruling | `security.md` + maintainer ruling | §4, §9, §11a, §13, §14 Q1 | +| Behaves "pretty well" under most known DoS attacks — scoped to *volume* by §14 Q1 | `security.md` + maintainer ruling | §4, §9, §11a, §13, §14 Q1 | | An enterprise-grade routing solution or LB (httpd, Nginx) is safer | `security.md` | §5, §10.1 | | Report privately per ASF guidelines; subscribe to announce@ | `security.md` | `SECURITY.md`, §1 | | Parsing limits (`max-uri-length`, `max-header-*`, `max-chunk-*`, `max-part-count`, …) | `http-core/reference.conf` | §5a, §8 P1 | @@ -412,12 +386,12 @@ Every other document **links** to `SECURITY.md` for the reporting policy and to | Timeouts and connection caps | `http-core/reference.conf` | §5a, §8 P5 | | `max-concurrent-streams = 256` | `http-core/reference.conf` | §5a, §8 P6 | | `max-header-list-size = 64 KiB`, bounding HEADERS + CONTINUATION accumulation | `http-core/reference.conf` | §5a, §8 P7 | -| `frame-type-throttle` charging `RST_STREAM` by default, against HTTP/2 Rapid Reset (CVE-2023-44487) | `http-core/reference.conf` | §5a, §8 P6 | -| HTTP/2 incoming buffer bounds (`incoming-connection-level-buffer-size = 10 MB`, `incoming-stream-level-buffer-size = 512kB`) and `outgoing-control-frame-buffer-size = 1024` | `http-core/reference.conf` | §5a, §8 P1 | -| Decoding limits (`decode-max-bytes-per-chunk = 1m`, `decode-max-size = 8m`), applied by the `decodeRequest*` directives | `http/reference.conf`, `CodingDirectives.scala` | §5a, §9 | +| `frame-type-throttle` charging `RST_STREAM` by default, against Rapid Reset (CVE-2023-44487) | `http-core/reference.conf` | §5a, §8 P6 | +| HTTP/2 incoming buffer bounds and `outgoing-control-frame-buffer-size` | `http-core/reference.conf` | §5a, §8 P1 | +| Decoding limits (`decode-max-bytes-per-chunk`, `decode-max-size`), applied by the `decodeRequest*` directives | `http/reference.conf`, `CodingDirectives.scala` | §5a, §9 | | `Credentials.verify` compares via constant-time `secure_==` | `SecurityDirectives.scala`, `EnhancedByteArray.scala` | §7, §8 P8, §9, §10.4, §14 Q5 | | A request that fails to parse is answered with a `400` on its own HTTP/2 stream | `RequestErrorFlow.scala` | §8 P9 | -| `safeDirectoryChildPath` rejects `..`/separator segments and enforces canonical containment (its string-prefix comparison was found defective in review; fixed by [#1218](https://github.com/apache/pekko-http/pull/1218)) | `FileAndResourceDirectives.scala` | §9, §14 Q3 | +| `safeDirectoryChildPath` rejects `..`/separator segments and enforces canonical containment | `FileAndResourceDirectives.scala` | §9, §14 Q3 | | `remote-address-attribute = off` | `http-core/reference.conf` | §5, §9, §14 Q4 | | `extractClientIP` reads `X-Forwarded-For`/`X-Real-Ip`; `extractDirectClientIP` reads the attribute alone | `MiscDirectives.scala` | §5, §6, §9, §10.5, §11a, §14 Q4 | | CORS: `*` + credentials echoes the request `Origin` | `http-cors/reference.conf` | §5a, §9, §11a, §14 Q2 |