masaori335 opened a new issue, #13647:
URL: https://github.com/apache/trafficserver/issues/13647

   ## 1. The issue
   
   `cripts::Header::operator[]` (`src/cripts/Headers.cc:227-246`) returns the 
value of the **first** field line only. RFC 9110 §5.3 makes repeated field 
lines semantically one comma-joined value, and `header_rewrite` implements 
exactly that (`plugins/header_rewrite/conditions.cc:286-298`, walks 
`TSMimeHdrFieldNextDup`, joins on a bare `,`). So the two disagree on any 
duplicated header — `Cache-Control`, `Via`, `Accept-Encoding`, `Warning`, and 
anything a client chose to split.
   
   The mechanism: `TSMimeHdrFieldValueStringGet(..., idx = -1)` reaches 
`TSMimeFieldValueGet`, whose `idx < 0` branch is 
`handle->field_ptr->value_get()` (`src/api/InkAPI.cc:1655-1666`) — that one 
field line, not the dup chain.
   
   **This is not a subtly wrong value, it's an inverted branch.** Client sends 
the field twice:
   
   ```
   X-Check: yes
   X-Check: no
   ```
   
   against `if (req["X-Check"] == "yes") { req.Erase("X-Check"); }`:
   
   | Read behaviour | Sees | Branch taken | Result |
   | --- | --- | --- | --- |
   | joined (`header_rewrite`) | `yes,no` | no | both lines reach the origin |
   | first-field-only (Cripts) | `yes` | **yes** | every line destroyed, header 
**absent** |
   
   ### Why this looks like an oversight rather than a decision
   
   The same file walks duplicates correctly in all three write paths and in 
neither read path:
   
   | `src/cripts/Headers.cc` | Walks `NextDup`? | Behaviour |
   | --- | --- | --- |
   | `:113-164` `operator=(string_view)` | yes | replaces the first line, 
destroys the rest |
   | `:166-199` `operator=(integer)` | yes | same |
   | `:118-128` empty-assign / `Erase` | yes | destroys every line |
   | `:227-246` `operator[]` | **no** | reads line one, ignores the rest |
   | `:248-264` `AsDate` | **no** | reads line one (defensible — comma-joining 
dates is nonsense) |
   
   Stronger still: `operator+=` (`:202-225`) **creates** a second field line 
rather than extending the value. Cripts can therefore write a header it cannot 
read back — `h["X"] = "a"; h["X"] += "b";` then reads as `a`. An implementation 
that deliberately treated headers as single-valued would not do that.
   
   ### Adjacent defects in the same code, found while investigating
   
   Listing these because any fix touches the same class, and two are 
memory-safety issues:
   
   | Where | Problem |
   | --- | --- |
   | `include/cripts/Headers.hpp:151-157` | `Header::String` has a destructor 
that releases `_field_loc`, but no rule-of-three. The implicit copy 
ctor/copy-assign duplicate the `TSMLoc`, so `h["A"] = h["B"]` (which binds the 
implicit copy-assign, not the `string_view` overload) double-releases → 
`sdk_free_field_handle` → `THREAD_FREE` twice on a proxy-allocator handle 
(`src/api/InkAPI.cc:863-870`, `:197-202`). `operator[]` itself is safe only 
because NRVO fires. |
   | `src/cripts/Headers.cc:209-211` | `operator+=` releases `_field_loc` 
without nulling it; `TSMimeHdrFieldCreateNamed` leaves `*locp` untouched on its 
`!isWriteable` early return (`src/api/InkAPI.cc:1904-1906`), so on a 
non-writeable heap `~String` releases the stale handle again. |
   | `src/cripts/Headers.cc:285-328` | `begin()`/`iterate()` use 
`TSMimeHdrFieldNext`, which steps the flat field list including duplicates 
(`src/api/InkAPI.cc:2038-2055`). The documented `for (auto h : req) CDebug("{}: 
{}", h, req[h])` idiom therefore prints a duplicated header's *first* value 
once per line. |
   | `src/cripts/Headers.cc:113-199` | After any assignment the proxy's 
`_field_loc` ends up `nullptr` and its cached value is stale, so 
read-after-write on the same proxy is wrong. |
   
   ## 2. Proposed fix
   
   **Make `operator[]` return the joined value, and expose the individual field 
lines.** Joining alone is not enough: `Set-Cookie` is explicitly exempted from 
list semantics by RFC 9110 §5.3 (its values contain commas and cannot be split 
back), so scripts need per-line access as a first-class API, not a workaround.
   
   ```cpp
   req["Accept-Encoding"];           // "gzip,br"   (was "gzip")
   req["Accept-Encoding"].Count();   // 2           (0 when absent)
   req["Accept-Encoding"].Values();  // {"gzip", "br"}
   ```
   
   ### Where the joined buffer lives — the one real design question
   
   `Header::String` is a `StringViewMixin` holding a `string_view` and no owned 
buffer, and `operator[]` returns it by value. A joined value needs backing 
store somewhere:
   
   | Option | Cost |
   | --- | --- |
   | **A. Own a `std::string` in the proxy** | `StringViewMixin::operator 
string_view()` is implicit (`include/cripts/Lulu.hpp:162`), so 
`cripts::string_view v = req["Cache-Control"];` would dangle at the end of the 
full expression — **but only when the header is duplicated**. Silently fine in 
test, wrong in production. Trades a wrong-value bug for a use-after-free on the 
same trigger. |
   | **B. Arena on the owning `Header`** ✅ | Cleared in `Header::Reset()`, 
which `Context::reset()` (`src/cripts/Context.cc:33`) already calls between 
hooks — i.e. exactly the lifetime a header view has today. No new dangling 
class. Must be node-stable (`std::list`), since `vector<std::string>` 
reallocation moves SSO strings and recreates A's bug. Allocates only when 
`TSMimeHdrFieldNextDup != nullptr`, so the single-line path is unchanged. |
   | **C. Leave `operator[]`, add `Joined()`** | No behaviour change, but the 
default stays wrong and every caller must know to avoid `[]`. |
   
   Proposing **B**.
   
   ### Prerequisite
   
   Delete `Header::String`'s copy/move and build it as a prvalue from a private 
ctor (guaranteed elision), plus a real `operator=(const String&)` that assigns 
the *value*. Without this, adding a buffer turns the latent double-free above 
into an easy one. This also fixes `h["A"] = h["B"]`, which is currently a 
memory bug.
   
   ### Scope questions for discussion
   
   1. **The iterator.** Once reads are joined, should iteration deduplicate 
names, or keep yielding one entry per field line? Keeping it is defensible 
("duplicates stay visible"), but it makes the documented print-all-headers 
idiom emit the joined value N times.
   2. **`AsDate`** — leave on the first field line?
   3. **Blast radius.** Any Cript comparing a header that can legitimately 
repeat starts seeing `a,b` where it saw `a`. Correct, but not what those 
scripts were written against, so this wants a release note in 
`doc/release-notes/upgrading.en.rst` rather than a quiet patch. Is v11 the 
right place, or does this need a deprecation path?
   
   I have a working implementation of the above (Option B + 
`Count()`/`Values()` + the rule-of-three fix), with an AuTest covering the 
joined read, per-line access, the inverted-branch case, and the write-path 
round trip. Happy to open a PR once there's agreement on 1–3.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to