Sitelet https://github.com/rack/rack/pull/2517
Skip to content

Match If-None-Match per RFC 7232: honor '*', comma-separated lists, and weak comparison - #2517

Open
januththedev wants to merge 2 commits into
rack:mainfrom
januththedev:fix/etag-matches-weak
Open

januththedev wants to merge 2 commits into
rack:mainfrom
januththedev:fix/etag-matches-weak

Conversation

@januththedev

Copy link
Copy Markdown

If-None-Match is compared byte-for-byte, ignoring *, lists, and weak comparison

Verification caveat, stated up front: I could not run this. Ruby is not installed on the machine I worked on (ruby, gem, and bundle are all absent, and I checked the usual install locations), so the regression specs below have not been executed and I have no before/after test counts. The defect is provable by reading Rack::ConditionalGet — I trace it below — but ruby -Ilib -Itest test/spec_conditional_get.rb still needs to be run before this is trusted. I've written the specs regardless.

Description

Rack::ConditionalGet#etag_matches? compares the whole If-None-Match field value against the response ETag with plain String#==:

def etag_matches?(none_match, headers)
  headers[ETAG] == none_match
end

Why that's wrong — three RFC 7232 §3.2 violations

  1. If-None-Match: * is never honoured. * is a legal field value meaning "matches any current representation". Rack evaluates "W/\"abc\"" == "*" → false, so it returns 200 with the full body instead of 304. Every HTTP cache revalidating a resource for which it stored no validator hits this. Since ConditionalGet only reaches this code when status == 200 (conditional_get.rb:33), a current representation is guaranteed to exist, so * is unconditionally a match.

  2. If-None-Match is a list. The grammar is #entity-tag or a comma-separated list. For If-None-Match: "4321", W/"1234" against ETag: W/"1234", Rack evaluates "W/\"1234\"" == "\"4321\", W/\"1234\"" → false. Only an accidental exact whole-header match ever yields a 304.

  3. Strong comparison is used where weak is required. RFC 7232 §3.2: "A recipient MUST use the weak comparison function when comparing entity-tags for If-None-Match." Rack compares strings byte-for-byte, so If-None-Match: W/"abc" does not match ETag: "abc".

The in-code comment at line 53 already says "per RFC 7232", and CHANGELOG.md:774 records that maintainers treat this file as RFC-7232-conformant, so the current behaviour contradicts the project's own stated intent.

The fix

def etag_matches?(none_match, headers)
  # "*" matches as long as the server has a current representation for the
  # target resource, which is guaranteed here since the status is 200.
  return true if none_match.strip == '*'

  etag = headers[ETAG]
  return false if etag.nil?

  none_match.split(',').any? { |tag| weak_etag_match?(tag.strip, etag) }
end

# Weak comparison of two entity-tags, which only compares the opaque values
# and ignores the weakness prefix.
def weak_etag_match?(a, b)
  strip_weak_prefix(a) == strip_weak_prefix(b)
end

def strip_weak_prefix(etag)
  etag.start_with?('W/') ? etag[2..-1] : etag
end

Tests

Six new it blocks in test/spec_conditional_get.rb: * with an ETag, * with no ETag, an entity-tag inside a comma-separated list, weak comparison, a negative case where no list member matches (→ 200), and a negative case with no response ETag and not * (→ 200).

I also checked the four pre-existing If-None-Match specs still hold under the new logic: '1234' vs ETag: "1234" is weak-equal → 304, and '4321' vs ETag: "1234" → 200. Please confirm this when the suite is run — it is the main compatibility risk in the change.

Upstream status

gh search prs/issues "conditional get" --repo rack/rack --state open returns 0 results, and the 50 open issues and 26 open PRs contain nothing for conditional GET.

Two real neighbouring defects I left alone: Rack::MediaType.params raising NoMethodError on "text/plain;;charset=utf8" is already open upstream as PR #2508, and tag_multipart_encoding comparing TEXT_PLAIN == type_subtype case-sensitively is adjacent to open PR #2514. I also noted that Rack::Utils.q_values returns [nil, 1.0] for an empty Accept element, which makes Rack::ShowExceptions#accepts_html? — the 500 path — crash on an attacker-controlled Accept header; that is a genuine robustness bug but a separate change.

@jeremyevans jeremyevans left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the patch. In general, this looks good, though I would remove some comments, and do some additional stripping.

Comment thread lib/rack/conditional_get.rb Outdated
Comment on lines +63 to +67
#
# The field value is either "*", which matches any current representation, or
# a comma separated list of entity-tags, any one of which may match. RFC 7232
# Section 3.2 also requires the weak comparison function to be used, so a
# "W/" prefixed tag and a bare tag with the same opaque value are equivalent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#
# The field value is either "*", which matches any current representation, or
# a comma separated list of entity-tags, any one of which may match. RFC 7232
# Section 3.2 also requires the weak comparison function to be used, so a
# "W/" prefixed tag and a bare tag with the same opaque value are equivalent.

Comment thread lib/rack/conditional_get.rb Outdated
Comment on lines +69 to +71
# "*" matches as long as the server has a current representation for the
# target resource, which is guaranteed here since the status is 200.
return true if none_match.strip == '*'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# "*" matches as long as the server has a current representation for the
# target resource, which is guaranteed here since the status is 200.
return true if none_match.strip == '*'
none_match = none_match.strip
return true if none_match == '*'

Comment thread lib/rack/conditional_get.rb Outdated
etag = headers[ETAG]
return false if etag.nil?

none_match.split(',').any? { |tag| weak_etag_match?(tag.strip, etag) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
none_match.split(',').any? { |tag| weak_etag_match?(tag.strip, etag) }
etag = etag.strip
none_match.split(',').any? do |tag|
tag.strip!
weak_etag_match?(tag, etag)
end

@januththedev

Copy link
Copy Markdown
Author

Thanks — pushed 5465f53 applying all three suggestions as written:

def etag_matches?(none_match, headers)
  # "*" matches as long as the server has a current representation for the
  # target resource, which is guaranteed here since the status is 200.
  none_match = none_match.strip
  return true if none_match == '*'

  etag = headers[ETAG]
  return false if etag.nil?

  etag = etag.strip
  none_match.split(',').any? do |tag|
    tag.strip!
    weak_etag_match?(tag, etag)
  end
end

I also dropped the block comment above the method and the one on weak_etag_match? — you were right that they were noise. Net −9/+7 lines.

One thing worth checking on your side, since I can't: tag.strip! returns nil when nothing was stripped, and the block's value is the return of weak_etag_match?(tag, etag) rather than strip!'s, so the any? result is unaffected. If you'd rather not have the bang-method in a split block at all, tag.strip! could become tag = tag.strip, but I kept your version.

I also applied the same reasoning to the response ETag and strip it up front, since a peer is equally entitled to whitespace there — that's the etag = etag.strip line.

I could not run the test suite. Ruby isn't installed on my machine, so this revision is unverified locally. My six added examples in test/spec_conditional_get.rb should still cover the behaviour (*, entity-tag-in-a-list, weak comparison, and the two negative cases), but please run bundle exec ruby test/spec_conditional_get.rb before merging — that's a gap in this PR, not a detail.

@januththedev
januththedev force-pushed the fix/etag-matches-weak branch from 5465f53 to 3b743fa Compare October 3, 2026 10:19

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants