Version
github.com/danielgtaylor/huma/v2 v2.39.1 (module cache path:
github.com/danielgtaylor/huma/v2@v2.39.1), specifically the
conditional subpackage combined with the core request-parameter parser
in huma.go.
Summary
A multi-value If-None-Match (or If-Match) header whose elements are
separated by ", " (comma + a space, or any other optional whitespace) —
which RFC 9110 §5.6.1's list grammar explicitly permits — fails to match
an ETag that would match if the same header were sent without the space.
This means conditional.Params.PreconditionFailed can silently miss a
legitimate conditional-request match for any list element after the
first, causing a 304-eligible request to instead return a full 200 with
the complete response body.
Root cause
-
huma.go's parameter parser splits a list-style header/query param on
a literal comma with no whitespace handling:
// huma.go:1871 (parseInto, reflect.Slice case, non-Explode path)
values = strings.Split(value, ",")
For If-None-Match: "a", "b" this produces []string{"a", "b"} —
note the leading space preserved on the second element.
-
conditional.trimETag (conditional/params.go:24-29) is the only place
that later tries to normalize an incoming list element for comparison,
and it only strips a W/ prefix and " characters:
// conditional/params.go:24-29
func trimETag(value string) string {
if strings.HasPrefix(value, "W/") && len(value) > 2 {
value = value[2:]
}
return strings.Trim(value, "\"")
}
strings.Trim(value, "\"") only removes leading and trailing characters
that are themselves ". Given "b" the leading character is a space,
not a quote, so Trim stops immediately on that side and strips only the
trailing quote:
input=" \"b\"" Trim(input, "\"") = " \"b" equals bare tag "b"? false
The element keeps both its leading space and its opening quote, so it can
never equal the bare tag a caller passes into PreconditionFailed.
-
PreconditionFailed (conditional/params.go:66-152) then compares
trimETag(match) == etag for each list element, so the space-tainted
element never matches even though its "real" value is a legitimate,
correctly-quoted entity-tag.
RFC citation
RFC 9110 §5.6.1 defines the list extension used by If-Match (§13.1.1)
and If-None-Match (§13.1.2):
#element => [ element ] *( OWS "," OWS [ element ] )
OWS (optional whitespace) is explicitly part of the grammar around each
comma. A client sending If-None-Match: "a", "b" — the conventional,
human-readable way to write a comma list, and what net/http's own
http.Header.Add-then-join behavior and most intermediary proxies/CDNs
produce — is fully RFC-conformant. huma should not require callers to
omit that whitespace to get a correct match.
Minimal reproduction
package main
import (
"fmt"
"time"
"github.com/danielgtaylor/huma/v2/conditional"
)
func main() {
p := &conditional.Params{
// Note the space after the comma — RFC 9110 §5.6.1 OWS.
IfNoneMatch: []string{`"other-tag"`, ` "target-tag"`},
}
// A resource whose current ETag is "target-tag".
err := p.PreconditionFailed("target-tag", time.Time{})
if err != nil {
fmt.Println("matched (304 expected):", err.GetStatus())
} else {
fmt.Println("did NOT match (200 will be served) — bug reproduced")
}
}
Or, against any server built on humago + conditional.Params embedded
in an operation's input struct:
curl -i -H 'If-None-Match: "other-tag", "target-tag"' https://example/resource
Expected vs. actual
| Request |
Expected |
Actual |
If-None-Match: "target-tag" |
304 |
304 (correct) |
If-None-Match: "other-tag","target-tag" (no space) |
304 |
304 (correct) |
If-None-Match: "other-tag", "target-tag" (RFC-legal space) |
304 |
200 |
If-None-Match: "target-tag", "other-tag" (space lands on the other element) |
304 |
304 (correct, but only by luck of position) |
Only the first list element is ever compared reliably; every subsequent
element is one un-trimmed leading space away from silently failing to
match.
Suggested fix
Trim OWS (" \t") from each element after splitting, either at the split
site or inside trimETag. Smallest fix, at the split site in
huma.go's parseInto:
// huma.go:1871
values = strings.Split(value, ",")
for i, v := range values {
values[i] = strings.Trim(v, " \t") // RFC 9110 §5.6.1 OWS
}
This is a general fix for every list-style header/query param huma parses
(not conditional-specific), which is probably the more correct place
for it — conditional.trimETag only ever sees what parseInto already
handed it, so fixing the split site fixes every caller at once rather
than just this one package.
Version
github.com/danielgtaylor/huma/v2 v2.39.1(module cache path:github.com/danielgtaylor/huma/v2@v2.39.1), specifically theconditionalsubpackage combined with the core request-parameter parserin
huma.go.Summary
A multi-value
If-None-Match(orIf-Match) header whose elements areseparated by
", "(comma + a space, or any other optional whitespace) —which RFC 9110 §5.6.1's list grammar explicitly permits — fails to match
an ETag that would match if the same header were sent without the space.
This means
conditional.Params.PreconditionFailedcan silently miss alegitimate conditional-request match for any list element after the
first, causing a 304-eligible request to instead return a full 200 with
the complete response body.
Root cause
huma.go's parameter parser splits a list-style header/query param ona literal comma with no whitespace handling:
For
If-None-Match: "a", "b"this produces[]string{"a","b"}—note the leading space preserved on the second element.
conditional.trimETag(conditional/params.go:24-29) is the only placethat later tries to normalize an incoming list element for comparison,
and it only strips a
W/prefix and"characters:strings.Trim(value, "\"")only removes leading and trailing charactersthat are themselves
". Given"b"the leading character is a space,not a quote, so
Trimstops immediately on that side and strips only thetrailing quote:
The element keeps both its leading space and its opening quote, so it can
never equal the bare tag a caller passes into
PreconditionFailed.PreconditionFailed(conditional/params.go:66-152) then comparestrimETag(match) == etagfor each list element, so the space-taintedelement never matches even though its "real" value is a legitimate,
correctly-quoted entity-tag.
RFC citation
RFC 9110 §5.6.1 defines the list extension used by
If-Match(§13.1.1)and
If-None-Match(§13.1.2):OWS(optional whitespace) is explicitly part of the grammar around eachcomma. A client sending
If-None-Match: "a", "b"— the conventional,human-readable way to write a comma list, and what
net/http's ownhttp.Header.Add-then-join behavior and most intermediary proxies/CDNsproduce — is fully RFC-conformant. huma should not require callers to
omit that whitespace to get a correct match.
Minimal reproduction
Or, against any server built on
humago+conditional.Paramsembeddedin an operation's input struct:
Expected vs. actual
If-None-Match: "target-tag"If-None-Match: "other-tag","target-tag"(no space)If-None-Match: "other-tag", "target-tag"(RFC-legal space)If-None-Match: "target-tag", "other-tag"(space lands on the other element)Only the first list element is ever compared reliably; every subsequent
element is one un-trimmed leading space away from silently failing to
match.
Suggested fix
Trim OWS (
" \t") from each element after splitting, either at the splitsite or inside
trimETag. Smallest fix, at the split site inhuma.go'sparseInto:This is a general fix for every list-style header/query param huma parses
(not
conditional-specific), which is probably the more correct placefor it —
conditional.trimETagonly ever sees whatparseIntoalreadyhanded it, so fixing the split site fixes every caller at once rather
than just this one package.