Skip to content

Commit cd9fa04

Browse files
committed
fix(net-http-headers): accept the obsolete HTTP-date forms, and stop reading a short year as 94 AD (#2130)
The shared parser accepted only the preferred IMF-fixdate form. RFC 9110 5.6.7 requires a RECIPIENT to accept three. #2125 was right on both counts: neither obsolete form had ever been accepted here, so its full-consumption repair could not have narrowed one away, and closing the gap "is a WIDENING and belongs to #2130, which is deferred because /rv is absent". It is not absent now, and the answer is yes -- HttpDateParser .TryParse tries strict "r" and then twenty-one format strings, four of them RFC 850 and one ANSI C asctime. All three required forms now parse, and a test asserts they denote the SAME instant rather than checking each separately. THE WIDENING UNCOVERED A LATENT DEFECT, and it is the most valuable thing here. "Sun, 06 Nov 94 08:49:37 GMT" was not rejected: the %d conversion read 94 literally, so the parser reported the year 94 AD. A silently wrong instant, off by nineteen centuries, and no test had noticed. .NET accepts the same text and reads 1994. That row is therefore a CORRECTION, not a widening -- the port's answer was wrong rather than merely strict. The two-digit-year window is .NET's, not the naive 1900+yy: DateTimeFormatInfo.InvariantInfo's calendar has TwoDigitYearMax == 2029, so 00..29 are 2000..2029 and 30..99 are 1930..1999. Both ends are pinned and the naive window is caught by a mutation. Only an exactly-two-digit token is expanded, so a four-digit year keeps meaning what it says. The obsolete forms obey every rule the preferred form does: #2125's full-consumption rule and the embedded-NUL guard both apply, and a mutation catches RFC 850 skipping the consumption check. Sixteen further .NET formats are deliberately NOT adopted and are pinned as such. They are leniency rather than required forms; each carries its own ambiguity -- a bare time is only UTC because .NET assumes it is, and an RFC 5322 offset means the value is NOT UTC, which every consumer of this parser currently assumes it is. That is ticket #2360. +4 tests, the gated pin replaced. Five mutations, all caught. Gate: 17,251 run, 0 failed, 38 executables -- green. Record: docs/Migration-HttpDateObsoleteFormats.md.
1 parent d7ad928 commit cd9fa04

5 files changed

Lines changed: 328 additions & 47 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — the two obsolete HTTP-date forms are accepted, and a short year stops being wrong by 1900 years (ticket #2130)
5+
6+
*2026-08-17.* The shared HTTP-date parser accepted only the preferred IMF-fixdate form. RFC 9110
7+
§5.6.7 requires a **recipient** to accept three. All three are accepted now.
8+
9+
It also uncovered a latent defect: a two-digit year on an IMF-fixdate was **accepted with the
10+
wrong value**.
11+
12+
Landed under `docs/StandingApprovals.md` SA-5. Implementation-only header; no public signature,
13+
layout, vtable or `noexcept` change.
14+
15+
---
16+
17+
## 1. What changed
18+
19+
| Value | Was | Is |
20+
|---|---|---|
21+
| `Sun, 06 Nov 1994 08:49:37 GMT` | accepted | **unchanged** |
22+
| `Sunday, 06-Nov-94 08:49:37 GMT` (RFC 850) | rejected | accepted, 1994-11-06T08:49:37Z |
23+
| `Sun Nov 6 08:49:37 1994` (asctime) | rejected | accepted, the same instant |
24+
| `Sun, 06 Nov 94 08:49:37 GMT` | **accepted as the year 94 AD** | accepted as **1994** |
25+
| trailing text after any of them | rejected | **rejected** |
26+
| an embedded NUL | rejected | **rejected** |
27+
| `Sun, 06 Nov 1994 08:49:37 UTC` and fifteen other lenient .NET forms | rejected | **rejected** — see §4 |
28+
29+
All three required forms parse to the same instant, and a test asserts that rather than checking
30+
each separately.
31+
32+
## 2. Why this stopped being a deferral
33+
34+
#2125 recorded, correctly, that neither obsolete form had *ever* been accepted here — so its
35+
full-consumption repair could not have narrowed a required form away — and that closing the gap
36+
*"is a WIDENING and belongs to #2130, which is deferred because `/rv` is absent and .NET's own
37+
behaviour cannot be established here."*
38+
39+
It can be now. `HttpDateParser.TryParse` tries strict `"r"` and then **twenty-one** format
40+
strings, four of them RFC 850 and one ANSI C `asctime`
41+
(`Common/src/System/Net/HttpDateParser.cs:9-32`).
42+
43+
## 3. The latent defect the widening uncovered
44+
45+
`Sun, 06 Nov 94 08:49:37 GMT` was **not** rejected. The `%d` conversion read `94` literally, so
46+
the parser reported the year **94 AD** — a silently wrong instant, off by nineteen centuries, and
47+
no test had noticed. .NET accepts the same text and reads 1994
48+
(`"ddd, d MMM yy H:m:s 'GMT'"`, `HttpDateParser.cs:17`).
49+
50+
So this row is a **correction**, not a widening: the port's answer was wrong rather than merely
51+
strict.
52+
53+
The window is .NET's: `DateTimeFormatInfo.InvariantInfo`'s Gregorian calendar has
54+
`TwoDigitYearMax == 2029`, so `00`..`29` are 2000..2029 and `30`..`99` are 1930..1999. The naive
55+
`1900 + yy` — which turns `06` into 1906 — is caught by a mutation. Only an **exactly**
56+
two-digit token is expanded, so a four-digit year keeps meaning exactly what it says.
57+
58+
## 4. What is deliberately *not* adopted
59+
60+
.NET's remaining sixteen formats are **leniency**, not required forms: a `UTC` zone token
61+
instead of `GMT`, no zone token at all, a missing day-of-week, and RFC 5322 numeric offsets.
62+
Adopting them would accept text RFC 9110 does not define as an HTTP-date — a much larger
63+
widening than this ticket asked for — and each carries its own ambiguity: a bare time with no
64+
zone is only UTC because .NET *assumes* it is, and an RFC 5322 numeric offset means the value is
65+
**not** UTC, which every consumer of this parser currently assumes it is.
66+
67+
That is ticket **#2360**, and the current behaviour is pinned so it cannot land by accident.
68+
69+
## 5. To migrate
70+
71+
Nothing, unless you were relying on an obsolete-format `Date`, `Expires`, `Last-Modified`,
72+
`Retry-After` or `If-Range` header being **rejected**. Those senders are non-conforming but
73+
RFC 9110 requires you to accept them.
74+
75+
If you were reading a two-digit-year IMF date, the value you got was wrong and is now right.
76+
77+
## 6. Downstream, measured
78+
79+
Neither `cna` nor `mobile-eggbert` references `System::Net::Http`**zero sites in both**.
80+
Neither repository was modified.

modules/net-http-headers/src/System/Net/Http/Headers/HttpDateParser.hpp

Lines changed: 140 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -64,51 +64,159 @@ namespace System::Net::Http::Headers::detail {
6464
* makes the value invalid; trailing *whitespace* does not, because whitespace was accepted
6565
* before #2125 and is not what the finding is about.
6666
*/
67-
inline bool TryParseHttpDate(const std::string& s, System::DateTimeOffset& result) {
67+
/** @brief The three-letter month names, in order, as every HTTP-date form spells them. */
68+
inline int MonthIndexFromName(const char* name) {
6869
static constexpr std::array<const char*, 12> months = {
6970
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
71+
for (int i = 0; i < 12; ++i) {
72+
if (std::strcmp(name, months[static_cast<size_t>(i)]) == 0) return i;
73+
}
74+
return -1;
75+
}
7076

71-
char dayName[4] = {};
72-
char monthName[4] = {};
73-
int day = 0, year = 0, hour = 0, minute = 0, second = 0;
74-
char gmt[4] = {};
75-
int consumed = -1;
77+
/**
78+
* @brief Expands an RFC 850 two-digit year the way .NET's invariant calendar does.
79+
*
80+
* Ticket #2130. `DateTimeFormatInfo.InvariantInfo`'s Gregorian calendar has
81+
* `TwoDigitYearMax == 2029`, so `00`..`29` are 2000..2029 and `30`..`99` are 1930..1999.
82+
* Getting this wrong is the easy mistake in RFC 850 support -- a naive `1900 + yy` turns
83+
* `Sunday, 06-Nov-06 ...` into 1906 -- so it is pinned by a test at both ends of the window.
84+
*/
85+
inline int ExpandTwoDigitYear(int twoDigitYear) {
86+
return twoDigitYear <= 29 ? 2000 + twoDigitYear : 1900 + twoDigitYear;
87+
}
88+
89+
/** @brief Builds the result, returning false for a date the calendar rejects. */
90+
inline bool BuildHttpDate(int year, int monthIndex, int day, int hour, int minute, int second,
91+
System::DateTimeOffset& result) {
92+
if (monthIndex < 0) return false;
93+
try {
94+
result = System::DateTimeOffset(year, monthIndex + 1, day, hour, minute, second,
95+
System::TimeSpan::Zero);
96+
return true;
97+
} catch (...) {
98+
return false;
99+
}
100+
}
101+
102+
/** @brief True when everything from @p from to the end of @p s is whitespace. */
103+
inline bool OnlyTrailingWhitespace(const std::string& s, int from) {
104+
if (from < 0) return false;
105+
for (size_t i = static_cast<size_t>(from); i < s.size(); ++i) {
106+
if (std::isspace(static_cast<unsigned char>(s[i])) == 0) return false;
107+
}
108+
return true;
109+
}
76110

111+
/**
112+
* @brief Parses one HTTP-date in any of RFC 9110 §5.6.7's three forms, consuming the whole
113+
* value.
114+
*
115+
* @param s The candidate field value.
116+
* @param result Receives the parsed instant (UTC) on success.
117+
* @return true only if @p s is a complete HTTP-date. Trailing text makes the value invalid;
118+
* trailing *whitespace* does not, which is #2125's rule and is unchanged.
119+
*
120+
* ---
121+
*
122+
* **Ticket #2130 (deferred verification) — the obsolete forms are now accepted.**
123+
*
124+
* #2125 recorded, correctly, that neither obsolete form had *ever* been accepted here, so it
125+
* could not have narrowed one away; and that whether .NET accepts them "is the still-open
126+
* question #2130, and it is a widening, not something this ticket may guess at". The
127+
* reference settles it: `HttpDateParser.TryParse` tries strict `"r"` and then **twenty-one**
128+
* format strings, of which four are RFC 850 and one is ANSI C's `asctime`
129+
* (`Common/src/System/Net/HttpDateParser.cs:9-32`).
130+
*
131+
* RFC 9110 §5.6.7 requires a **recipient** to accept all three, so all three are accepted:
132+
*
133+
* | Form | Example |
134+
* |---|---|
135+
* | IMF-fixdate (preferred) | `Sun, 06 Nov 1994 08:49:37 GMT` |
136+
* | RFC 850 (obsolete) | `Sunday, 06-Nov-94 08:49:37 GMT` |
137+
* | ANSI C `asctime` (obsolete) | `Sun Nov 6 08:49:37 1994` |
138+
*
139+
* **What is deliberately NOT adopted, and it is most of .NET's list.** The remaining sixteen
140+
* formats are *leniency*, not required forms: a `UTC` zone token instead of `GMT`, no zone
141+
* token at all, a missing day-of-week, a two-digit year on an IMF-fixdate, and RFC 5322
142+
* numeric offsets. Adopting them would accept text RFC 9110 does not define as an HTTP-date,
143+
* which is a much larger widening than the one this ticket asked for, and each has its own
144+
* ambiguities (a bare `08:49:37` with no zone is only UTC because .NET *assumes* it is). That
145+
* gap is recorded as ticket **#2360** rather than smuggled in here.
146+
*
147+
* `asctime` carries no zone; like .NET's `DateTimeStyles.AssumeUniversal`, it is read as UTC.
148+
*/
149+
inline bool TryParseHttpDate(const std::string& s, System::DateTimeOffset& result) {
77150
// An embedded NUL would let sscanf stop early and report a complete match over a prefix,
78-
// so it is rejected before c_str() hides the rest of the value.
151+
// so it is rejected before c_str() hides the rest of the value (#2125).
79152
if (s.find('\0') != std::string::npos) return false;
80153

81-
// The three character-array arguments carry their buffer size on MSVC, which its secure
82-
// scanf requires for %[ and %s; %n and the numeric conversions take no extra argument
83-
// there, so the conversion string and its result are the same on every compiler.
84-
const int matched = SHARP_RUNTIME_SSCANF(
85-
s.c_str(), "%3[A-Za-z], %d %3[A-Za-z] %d %d:%d:%d %3s%n",
86-
SHARP_RUNTIME_SCANF_BUFFER(dayName), &day, SHARP_RUNTIME_SCANF_BUFFER(monthName),
87-
&year, &hour, &minute, &second, SHARP_RUNTIME_SCANF_BUFFER(gmt), &consumed);
88-
if (matched != 8) return false;
89-
if (consumed < 0) return false;
90-
if (std::strcmp(gmt, "GMT") != 0) return false;
154+
char dayName[16] = {};
155+
char monthName[4] = {};
156+
int day = 0, year = 0, hour = 0, minute = 0, second = 0;
157+
char zone[4] = {};
158+
int consumed = -1;
91159

92-
// #2125: the whole value must be consumed. Anything but whitespace after the GMT token
93-
// means this was not one HTTP-date, and silently discarding it is how a cache key, a
94-
// conditional request or a log line ends up disagreeing with the sender.
95-
for (size_t i = static_cast<size_t>(consumed); i < s.size(); ++i) {
96-
if (std::isspace(static_cast<unsigned char>(s[i])) == 0) return false;
160+
// 1. IMF-fixdate -- the preferred form, and the only one this parser used to accept.
161+
// The conversion string is #2125's verbatim apart from the two %n markers that
162+
// bracket the year, so a value that parsed before parses to exactly the same instant
163+
// now -- with ONE correction, below.
164+
int yearBegin = -1, yearEnd = -1;
165+
if (SHARP_RUNTIME_SSCANF(
166+
s.c_str(), "%3[A-Za-z], %d %3[A-Za-z] %n%d%n %d:%d:%d %3s%n",
167+
SHARP_RUNTIME_SCANF_BUFFER(dayName), &day, SHARP_RUNTIME_SCANF_BUFFER(monthName),
168+
&yearBegin, &year, &yearEnd, &hour, &minute, &second,
169+
SHARP_RUNTIME_SCANF_BUFFER(zone), &consumed) == 8 &&
170+
std::strcmp(zone, "GMT") == 0 && OnlyTrailingWhitespace(s, consumed)) {
171+
// #2130, and this is a CORRECTION rather than a widening. `%d` read a two-digit year
172+
// literally, so "Sun, 06 Nov 94 08:49:37 GMT" was ACCEPTED and reported the year
173+
// **94 AD** -- a silently wrong instant, off by nineteen centuries, not a rejected
174+
// format. .NET accepts the same text and reads 1994 ("ddd, d MMM yy H:m:s 'GMT'",
175+
// HttpDateParser.cs:17), so the port's answer was wrong rather than merely strict.
176+
//
177+
// Only an exactly-two-digit token is expanded. Every other width keeps the value it
178+
// had, so nothing else moves.
179+
if (yearBegin >= 0 && yearEnd == yearBegin + 2 && year >= 0 && year <= 99) {
180+
year = ExpandTwoDigitYear(year);
181+
}
182+
return BuildHttpDate(year, MonthIndexFromName(monthName), day, hour, minute, second,
183+
result);
97184
}
98185

99-
int monthIndex = -1;
100-
for (int i = 0; i < 12; ++i) {
101-
if (std::strcmp(monthName, months[static_cast<size_t>(i)]) == 0) { monthIndex = i; break; }
186+
// 2. RFC 850 -- a FULL weekday name, hyphen-separated date, two-digit year.
187+
// HttpDateParser.cs:22 -- "dddd, d'-'MMM'-'yy H:m:s 'GMT'".
188+
std::memset(dayName, 0, sizeof(dayName));
189+
std::memset(monthName, 0, sizeof(monthName));
190+
std::memset(zone, 0, sizeof(zone));
191+
consumed = -1;
192+
int twoDigitYear = 0;
193+
if (SHARP_RUNTIME_SSCANF(
194+
s.c_str(), "%15[A-Za-z], %d-%3[A-Za-z]-%d %d:%d:%d %3s%n",
195+
SHARP_RUNTIME_SCANF_BUFFER(dayName), &day, SHARP_RUNTIME_SCANF_BUFFER(monthName),
196+
&twoDigitYear, &hour, &minute, &second, SHARP_RUNTIME_SCANF_BUFFER(zone),
197+
&consumed) == 8 &&
198+
std::strcmp(zone, "GMT") == 0 && OnlyTrailingWhitespace(s, consumed) &&
199+
twoDigitYear >= 0 && twoDigitYear <= 99) {
200+
return BuildHttpDate(ExpandTwoDigitYear(twoDigitYear), MonthIndexFromName(monthName),
201+
day, hour, minute, second, result);
102202
}
103-
if (monthIndex < 0) return false;
104203

105-
try {
106-
result = System::DateTimeOffset(year, monthIndex + 1, day, hour, minute, second,
107-
System::TimeSpan::Zero);
108-
return true;
109-
} catch (...) {
110-
return false;
204+
// 3. ANSI C asctime -- no zone, no comma, and a SPACE-PADDED day.
205+
// HttpDateParser.cs:26 -- "ddd MMM d H:m:s yyyy". sscanf's %d already skips the
206+
// padding, so "Nov 6" and "Nov 16" both work without a second conversion string.
207+
std::memset(dayName, 0, sizeof(dayName));
208+
std::memset(monthName, 0, sizeof(monthName));
209+
consumed = -1;
210+
if (SHARP_RUNTIME_SSCANF(
211+
s.c_str(), "%3[A-Za-z] %3[A-Za-z] %d %d:%d:%d %d%n",
212+
SHARP_RUNTIME_SCANF_BUFFER(dayName), SHARP_RUNTIME_SCANF_BUFFER(monthName), &day,
213+
&hour, &minute, &second, &year, &consumed) == 7 &&
214+
OnlyTrailingWhitespace(s, consumed)) {
215+
return BuildHttpDate(year, MonthIndexFromName(monthName), day, hour, minute, second,
216+
result);
111217
}
218+
219+
return false;
112220
}
113221

114222
} // namespace System::Net::Http::Headers::detail

0 commit comments

Comments
 (0)