|
28 | 28 |
|
29 | 29 | from typing import TYPE_CHECKING, Protocol |
30 | 30 |
|
| 31 | +from .errors import DPoPMultipleProofsError |
| 32 | + |
31 | 33 | if TYPE_CHECKING: |
32 | 34 | import asyncio |
33 | 35 |
|
@@ -61,6 +63,8 @@ class _HeadersLike(Protocol): |
61 | 63 |
|
62 | 64 | def get(self, key: str, default: str | None = ...) -> str | None: ... |
63 | 65 |
|
| 66 | + def getlist(self, key: str) -> list[str]: ... |
| 67 | + |
64 | 68 |
|
65 | 69 | class _URLLike(Protocol): |
66 | 70 | """Minimal slice of ``starlette.datastructures.URL``.""" |
@@ -109,15 +113,44 @@ def __init__(self, method: str, url: str, proof: str | None) -> None: |
109 | 113 |
|
110 | 114 |
|
111 | 115 | def read_dpop_header(request: _RequestLike) -> str | None: |
112 | | - """Read the ``DPoP`` request header (case-insensitive, first value). |
| 116 | + """Read the ``DPoP`` request header, enforcing RFC 9449 §4.3 #1. |
| 117 | +
|
| 118 | + Returns the single proof JWT when exactly one non-empty ``DPoP`` |
| 119 | + header value is present, or ``None`` when no ``DPoP`` header is |
| 120 | + present. Raises :class:`DPoPMultipleProofsError` when the request |
| 121 | + carries more than one ``DPoP`` header value. |
| 122 | +
|
| 123 | + Two on-wire shapes are rejected: |
| 124 | +
|
| 125 | + 1. Multiple ``DPoP`` headers on the request (``headers.getlist`` |
| 126 | + returns ≥ 2 non-empty entries). |
| 127 | + 2. A single ``DPoP`` header value pre-joined with ``,`` by an |
| 128 | + upstream proxy or framework — RFC 9110 §5.3 permits combining |
| 129 | + repeated headers this way. JWS compact serialization never |
| 130 | + contains a literal comma, so split-on-comma is sound. |
113 | 131 |
|
114 | | - Starlette's ``Headers.get`` already does case-insensitive lookup |
115 | | - and returns the first occurrence when a header is repeated. Strict |
116 | | - rejection of repeated ``DPoP`` headers (RFC 9449 §4.3 #1) is |
117 | | - tracked separately and is intentionally not enforced here so this |
118 | | - layer does not overlap with that work. |
| 132 | + Trimming and empty-piece filtering mirror the cross-language |
| 133 | + cardinality boundary so a request carrying ``"DPoP: "`` (whitespace |
| 134 | + only) is treated as header-absent rather than as one value. |
119 | 135 | """ |
120 | | - return request.headers.get("dpop") |
| 136 | + raw_values = request.headers.getlist("dpop") |
| 137 | + filtered: list[str] = [] |
| 138 | + for raw in raw_values: |
| 139 | + trimmed = raw.strip() |
| 140 | + if not trimmed: |
| 141 | + continue |
| 142 | + # ``split(",", 2)`` caps the allocation on an attacker-controlled |
| 143 | + # header: we only need 0 / 1 / ≥ 2 non-blank pieces, and a third |
| 144 | + # entry already trips the cardinality guard below. |
| 145 | + for part in trimmed.split(",", 2): |
| 146 | + piece = part.strip() |
| 147 | + if piece: |
| 148 | + filtered.append(piece) |
| 149 | + if len(filtered) > 1: |
| 150 | + raise DPoPMultipleProofsError( |
| 151 | + f"request carries {len(filtered)} DPoP proofs (RFC 9449 §4.3 forbids it)" |
| 152 | + ) |
| 153 | + return filtered[0] if filtered else None |
121 | 154 |
|
122 | 155 |
|
123 | 156 | def raw_request_path(request: _RequestLike) -> str: |
|
0 commit comments