Skip to content

Commit 1a9921e

Browse files
authored
Merge pull request #4508 from 0xghost42/fix/accepts-quality-zero-rejection
🐛 fix(ctx): honor q=0 rejections in content negotiation
2 parents c6f4afb + 6b7ab4e commit 1a9921e

2 files changed

Lines changed: 234 additions & 69 deletions

File tree

helpers.go

Lines changed: 156 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -243,19 +243,47 @@ func getGroupPath(prefix, path string) string {
243243
return utils.TrimRight(prefix, '/') + path
244244
}
245245

246+
// Match specificity levels returned by the acceptsX helpers. A higher value
247+
// means the range matched the offer more specifically. They only need to be
248+
// ordered consistently within a single helper (each getOffer call uses one
249+
// helper), so an explicit q=0 rejection can override a positive match of the
250+
// same or lower specificity per RFC 9110 §12.5.1.
251+
const (
252+
matchWildcard = 1 // "*" / trailing-"*" prefix / language "*"
253+
matchPrefix = 2 // language subtag prefix, e.g. "en" matching "en-US"
254+
matchExact = 3 // exact, case-insensitive match
255+
256+
// Media ranges rank by their coarse class first and by the number of
257+
// matched media-type parameters second, so "text/html;level=1" outranks
258+
// "text/html", which outranks "text/*", which outranks "*/*".
259+
matchMediaAny = 1 // "*/*"
260+
matchMediaTypeAny = 2 // "type/*"
261+
matchMediaTypeSubtype = 3 // "type/subtype"
262+
mediaSpecificityScale = 100
263+
)
264+
246265
// acceptsOffer determines if an offer matches a given specification.
247266
// It supports a trailing '*' wildcard and performs case-insensitive exact matching.
248-
// Returns true if the offer matches the specification, false otherwise.
249-
func acceptsOffer(spec, offer string, _ headerParams) bool {
267+
// It returns the match specificity (0 = no match, higher = more specific): a
268+
// wildcard/prefix match is less specific than an exact match. The specificity is
269+
// used to let an explicit q=0 rejection override a less specific positive match
270+
// of the same coarse class (RFC 9110 §12.5.1).
271+
func acceptsOffer(spec, offer string, _ headerParams) int {
250272
if len(spec) >= 1 && spec[len(spec)-1] == '*' {
251273
prefix := spec[:len(spec)-1]
252274
if len(offer) < len(prefix) {
253-
return false
275+
return 0
254276
}
255-
return utils.EqualFold(prefix, offer[:len(prefix)])
277+
if utils.EqualFold(prefix, offer[:len(prefix)]) {
278+
return matchWildcard
279+
}
280+
return 0
256281
}
257282

258-
return utils.EqualFold(spec, offer)
283+
if utils.EqualFold(spec, offer) {
284+
return matchExact
285+
}
286+
return 0
259287
}
260288

261289
// acceptsLanguageOfferBasic determines if a language tag offer matches a range
@@ -264,19 +292,25 @@ func acceptsOffer(spec, offer string, _ headerParams) bool {
264292
// followed by a hyphen. The comparison is case-insensitive. Only a single "*"
265293
// as the entire range is allowed. Any "*" appearing after a hyphen renders the
266294
// range invalid and will not match.
267-
func acceptsLanguageOfferBasic(spec, offer string, _ headerParams) bool {
295+
// It returns the match specificity (0 = no match): "*" is least specific, a
296+
// prefix match ("en" for "en-US") is more specific, and an exact match is most
297+
// specific — so an explicit "en-US;q=0" can override a positive "en".
298+
func acceptsLanguageOfferBasic(spec, offer string, _ headerParams) int {
268299
if spec == "*" {
269-
return true
300+
return matchWildcard
270301
}
271302
if strings.IndexByte(spec, '*') >= 0 {
272-
return false
303+
return 0
273304
}
274305
if utils.EqualFold(spec, offer) {
275-
return true
306+
return matchExact
276307
}
277-
return len(offer) > len(spec) &&
308+
if len(offer) > len(spec) &&
278309
utils.EqualFold(offer[:len(spec)], spec) &&
279-
offer[len(spec)] == '-'
310+
offer[len(spec)] == '-' {
311+
return matchPrefix
312+
}
313+
return 0
280314
}
281315

282316
// acceptsLanguageOfferExtended determines if a language tag offer matches a
@@ -285,12 +319,16 @@ func acceptsLanguageOfferBasic(spec, offer string, _ headerParams) bool {
285319
// - '*' matches zero or more subtags (can "slide")
286320
// - Unspecified subtags are treated like '*' (so trailing/extraneous tag subtags are fine)
287321
// - Matching fails if sliding encounters a singleton (incl. 'x')
288-
func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) bool {
322+
// It returns the match specificity (0 = no match): a bare "*" is least specific,
323+
// and otherwise the specificity grows with the number of concrete range subtags
324+
// that had to match, so a deeper range (e.g. "en-US") outranks a shorter one
325+
// ("en") and an explicit "en-US;q=0" can override a positive "en".
326+
func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) int {
289327
if spec == "*" {
290-
return true
328+
return matchWildcard
291329
}
292330
if spec == "" || offer == "" {
293-
return false
331+
return 0
294332
}
295333

296334
// Use stack-allocated arrays to avoid heap allocations for typical language tags
@@ -309,7 +347,7 @@ func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) bool {
309347

310348
// Step 2: first subtag must match (or be '*')
311349
if rs[0] != "*" && !utils.EqualFold(rs[0], ts[0]) {
312-
return false
350+
return 0
313351
}
314352

315353
i, j := 1, 1 // i = range index, j = tag index
@@ -319,7 +357,7 @@ func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) bool {
319357
continue
320358
}
321359
if j >= len(ts) { // 3.B: ran out of tag subtags
322-
return false
360+
return 0
323361
}
324362
if utils.EqualFold(rs[i], ts[j]) { // 3.C: exact subtag match
325363
i++
@@ -328,22 +366,33 @@ func acceptsLanguageOfferExtended(spec, offer string, _ headerParams) bool {
328366
}
329367
// 3.D: singleton barrier (one letter or digit, incl. 'x')
330368
if len(ts[j]) == 1 {
331-
return false
369+
return 0
332370
}
333371
// 3.E: slide forward in the tag and try again
334372
j++
335373
}
336-
// 4: matched all range subtags
337-
return true
374+
375+
// 4: matched all range subtags. Rank by the number of concrete (non-"*")
376+
// range subtags so a more specific range wins the specificity comparison.
377+
specificity := matchWildcard
378+
for _, sub := range rs {
379+
if sub != "*" {
380+
specificity++
381+
}
382+
}
383+
return specificity
338384
}
339385

340386
// acceptsOfferType This function determines if an offer type matches a given specification.
341387
// It checks if the specification is equal to */* (i.e., all types are accepted).
342388
// It gets the MIME type of the offer (either from the offer itself or by its file extension).
343389
// It checks if the offer MIME type matches the specification MIME type or if the specification is of the form <MIME_type>/* and the offer MIME type has the same MIME type.
344390
// It checks if the offer contains every parameter present in the specification.
345-
// Returns true if the offer type matches the specification, false otherwise.
346-
func acceptsOfferType(spec, offerType string, specParams headerParams) bool {
391+
// It returns the match specificity (0 = no match): "*/*" is least specific,
392+
// then "type/*", then "type/subtype", and matched media-type parameters break
393+
// ties so "text/html;level=1" outranks "text/html" (letting "text/html;level=1;q=0"
394+
// override a positive "text/html").
395+
func acceptsOfferType(spec, offerType string, specParams headerParams) int {
347396
var offerMime, offerParams string
348397

349398
if i := strings.IndexByte(offerType, ';'); i == -1 {
@@ -355,7 +404,7 @@ func acceptsOfferType(spec, offerType string, specParams headerParams) bool {
355404

356405
// Accept: */*
357406
if spec == "*/*" {
358-
return paramsMatch(specParams, offerParams)
407+
return mediaMatchSpecificity(matchMediaAny, specParams, offerParams)
359408
}
360409

361410
var mimetype string
@@ -367,19 +416,29 @@ func acceptsOfferType(spec, offerType string, specParams headerParams) bool {
367416

368417
if utils.EqualFold(spec, mimetype) {
369418
// Accept: <MIME_type>/<MIME_subtype>
370-
return paramsMatch(specParams, offerParams)
419+
return mediaMatchSpecificity(matchMediaTypeSubtype, specParams, offerParams)
371420
}
372421

373422
s := strings.IndexByte(mimetype, '/')
374423
specSlash := strings.IndexByte(spec, '/')
375424
// Accept: <MIME_type>/*
376425
if s != -1 && specSlash != -1 {
377426
if utils.EqualFold(spec[:specSlash], mimetype[:s]) && (spec[specSlash:] == "/*" || mimetype[s:] == "/*") {
378-
return paramsMatch(specParams, offerParams)
427+
return mediaMatchSpecificity(matchMediaTypeAny, specParams, offerParams)
379428
}
380429
}
381430

382-
return false
431+
return 0
432+
}
433+
434+
// mediaMatchSpecificity returns the specificity of a media range that matched an
435+
// offer's type/subtype, or 0 when the media-type parameters don't match. The
436+
// coarse class dominates; the count of matched parameters breaks ties.
437+
func mediaMatchSpecificity(base int, specParams headerParams, offerParams string) int {
438+
if !paramsMatch(specParams, offerParams) {
439+
return 0
440+
}
441+
return base*mediaSpecificityScale + len(specParams)
383442
}
384443

385444
// paramsMatch returns whether offerParams contains all parameters present in specParams.
@@ -601,7 +660,7 @@ var headerParamPool = sync.Pool{
601660
}
602661

603662
// getOffer return valid offer for header negotiation.
604-
func getOffer(header []byte, isAccepted func(spec, offer string, specParams headerParams) bool, offers ...string) string {
663+
func getOffer(header []byte, isAccepted func(spec, offer string, specParams headerParams) int, offers ...string) string {
605664
if len(offers) == 0 {
606665
return ""
607666
}
@@ -611,6 +670,9 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
611670

612671
acceptedTypes := make([]acceptedType, 0, 8)
613672
order := 0
673+
// Whether any range carries an explicit q=0 rejection. When none do, the
674+
// more-specific-rejection scan can be skipped entirely on the hot path.
675+
hasRejections := false
614676

615677
// Parse header and get accepted types with their quality and specificity
616678
// See: https://www.rfc-editor.org/rfc/rfc9110#name-content-negotiation-fields
@@ -651,12 +713,6 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
651713
return true
652714
})
653715
}
654-
655-
// Skip this accept type if quality is 0.0
656-
// See: https://www.rfc-editor.org/rfc/rfc9110#quality.values
657-
if quality == 0.0 {
658-
return
659-
}
660716
}
661717

662718
spec = utils.TrimSpace(spec)
@@ -678,6 +734,10 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
678734
specificity = 4
679735
}
680736

737+
if quality == 0 {
738+
hasRejections = true
739+
}
740+
681741
// Add to accepted types
682742
acceptedTypes = append(acceptedTypes, acceptedType{
683743
spec: utils.UnsafeString(spec),
@@ -693,25 +753,78 @@ func getOffer(header []byte, isAccepted func(spec, offer string, specParams head
693753
sortAcceptedTypes(acceptedTypes)
694754
}
695755

696-
// Find the first offer that matches the accepted types
697-
for _, acceptedType := range acceptedTypes {
698-
for _, offer := range offers {
699-
if offer == "" {
756+
// Find the best offer that matches the accepted types.
757+
//
758+
// Per RFC 9110 §12.5.1 the most specific matching media range determines an
759+
// offer's acceptability, and a quality of 0 means the client explicitly
760+
// rejects that range. An offer is therefore only acceptable if its most
761+
// specific matching range has a quality greater than 0 — a broader range
762+
// with a higher quality (e.g. "*" or "text/*") must not override a more
763+
// specific q=0 rejection.
764+
// See: https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1
765+
result := ""
766+
if !hasRejections {
767+
// Fast path: without any q=0 rejection this is the plain "first matching
768+
// range in preference order wins" selection, identical to the algorithm
769+
// before q=0 handling, so there is no need to compute or compare match
770+
// specificity.
771+
selectFast:
772+
for _, acceptedType := range acceptedTypes {
773+
for _, offer := range offers {
774+
if offer != "" && isAccepted(acceptedType.spec, offer, acceptedType.params) > 0 {
775+
result = offer
776+
break selectFast
777+
}
778+
}
779+
}
780+
} else {
781+
// Rejection-aware path: an offer is only acceptable if its matching range
782+
// is not overridden by a q=0 range that matches it at least as
783+
// specifically (RFC 9110 §12.5.1).
784+
selectWithRejections:
785+
for _, acceptedType := range acceptedTypes {
786+
if acceptedType.quality == 0 {
787+
// A q=0 range never selects an offer; it can only reject one.
700788
continue
701789
}
702-
if isAccepted(acceptedType.spec, offer, acceptedType.params) {
703-
if acceptedType.params != nil {
704-
headerParamPool.Put(acceptedType.params)
790+
for _, offer := range offers {
791+
if offer == "" {
792+
continue
793+
}
794+
matchSpecificity := isAccepted(acceptedType.spec, offer, acceptedType.params)
795+
if matchSpecificity > 0 &&
796+
!rejectedByMoreSpecificRange(acceptedTypes, isAccepted, offer, matchSpecificity) {
797+
result = offer
798+
break selectWithRejections
705799
}
706-
return offer
707800
}
708801
}
709-
if acceptedType.params != nil {
710-
headerParamPool.Put(acceptedType.params)
802+
}
803+
804+
for i := range acceptedTypes {
805+
if acceptedTypes[i].params != nil {
806+
headerParamPool.Put(acceptedTypes[i].params)
711807
}
712808
}
713809

714-
return ""
810+
return result
811+
}
812+
813+
// rejectedByMoreSpecificRange reports whether a q=0 range matches the offer at
814+
// least as specifically as the positive match at baseSpecificity, i.e. the
815+
// client explicitly rejected the offer per RFC 9110 §12.5.1. Comparing the
816+
// effective match specificity (rather than the coarse parsed bucket) lets a
817+
// same-class rejection win — e.g. "en-US;q=0" over "en", "utf-8;q=0" over an
818+
// earlier "utf-8", or "text/html;level=1;q=0" over "text/html" — while a less
819+
// specific rejection such as "text/*;q=0" still does not override "text/html".
820+
func rejectedByMoreSpecificRange(types []acceptedType, isAccepted func(spec, offer string, specParams headerParams) int, offer string, baseSpecificity int) bool {
821+
for i := range types {
822+
if types[i].quality == 0 &&
823+
isAccepted(types[i].spec, offer, types[i].params) >= baseSpecificity {
824+
return true
825+
}
826+
}
827+
return false
715828
}
716829

717830
// sortAcceptedTypes sorts accepted types by quality and specificity, preserving order of equal elements

0 commit comments

Comments
 (0)