fix(gateway): honor HTTP dates when revalidating assets and media (#140173)

* fix(gateway): honor UTC ordering in HTTP date validators

Use one strict singleton-date admission path for static and immutable byte
responses. Keep leap-second validators earlier than the following second,
while preserving the Retry-After ceiling and existing ETag/range policies.

Closes #140153.

* test(gateway): include distinct headers in static request fixtures
This commit is contained in:
Peter Steinberger 2026-09-06 08:28:13 -07:00 • committed by GitHub
parent 8404dc3c94
commit 8b6c26f388
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 195 additions and 35 deletions

View file

@ -1377,6 +1377,8 @@ Bundled public assets (themes, fonts, icons, and artwork) use `?v=<build-id>` UR
Non-index static assets use `Last-Modified` for conditional `GET` and `HEAD` requests. `If-None-Match` takes precedence over `If-Modified-Since`: `*` matches an existing asset, while other values receive the normal `200` response because static assets do not emit ETags. Date-only revalidation still returns `304` for unchanged assets. If no available content encoding is acceptable, the Gateway returns `406` before evaluating either condition.
All three HTTP-date formats are interpreted as UTC. Invalid or repeated `If-Modified-Since` fields are ignored, so they cannot suppress the current asset bytes. A leap-second validator remains earlier than the following second.
Static asset URLs support percent-encoded filenames. Contained symlinks retain the requested asset's MIME type, and a symlinked `index.html` receives the same base-path and document preparation as other entry routes.
Optional absolute base (fixed asset URLs):

View file

@ -29,6 +29,12 @@ type HttpDateComponents = {
seconds: number;
};
type HttpDateInstant = {
timestampMs: number;
// Date cannot represent :60; its following timestamp is an exclusive upper bound.
leapSecond: boolean;
};
function ownDataValue(value: unknown, key: string): unknown {
return value && typeof value === "object"
? Object.getOwnPropertyDescriptor(value, key)?.value
@ -106,11 +112,19 @@ export function parseRetryAfterErrorSeconds(
}
}
/** Parses the three HTTP-date forms accepted for Retry-After without Date.parse normalization. */
/** Round leap seconds forward for Retry-After so an unrepresentable instant cannot retry early. */
export function parseRetryAfterHttpDateMs(value: string, nowMs = Date.now()): number | undefined {
return parseHttpDateInstant(value, nowMs)?.timestampMs;
}
/** Parse all three HTTP-date forms while retaining leap-second ordering. */
export function parseHttpDateInstant(
value: string,
nowMs = Date.now(),
): HttpDateInstant | undefined {
const imfFixdate = IMF_FIXDATE_RE.exec(value);
if (imfFixdate) {
return parseHttpDateComponentsMs({
return parseHttpDateComponents({
weekday: HTTP_DATE_SHORT_WEEKDAY_INDEX.get(imfFixdate[1] ?? ""),
year: Number.parseInt(imfFixdate[4] ?? "", 10),
month: HTTP_DATE_MONTH_INDEX.get(imfFixdate[3] ?? ""),
@ -153,12 +167,12 @@ export function parseRetryAfterHttpDateMs(value: string, nowMs = Date.now()): nu
now.getUTCMilliseconds(),
);
const resolvedYear = candidate > fiftyYearsFromNow ? candidateYear - 100 : candidateYear;
return parseHttpDateComponentsMs({ year: resolvedYear, ...components });
return parseHttpDateComponents({ year: resolvedYear, ...components });
}
const asctimeDate = OBSOLETE_ASCTIME_DATE_RE.exec(value);
if (asctimeDate) {
return parseHttpDateComponentsMs({
return parseHttpDateComponents({
weekday: HTTP_DATE_SHORT_WEEKDAY_INDEX.get(asctimeDate[1] ?? ""),
year: Number.parseInt(asctimeDate[7] ?? "", 10),
month: HTTP_DATE_MONTH_INDEX.get(asctimeDate[2] ?? ""),
@ -172,16 +186,17 @@ export function parseRetryAfterHttpDateMs(value: string, nowMs = Date.now()): nu
return undefined;
}
function parseHttpDateComponentsMs(components: HttpDateComponents): number | undefined {
function parseHttpDateComponents(components: HttpDateComponents): HttpDateInstant | undefined {
const timestamp = parseHttpDateCalendarMs(components);
if (timestamp === undefined) {
return undefined;
}
const weekdayTimestamp = components.seconds === 60 ? timestamp - 1_000 : timestamp;
const leapSecond = components.seconds === 60;
const weekdayTimestamp = leapSecond ? timestamp - 1_000 : timestamp;
if (new Date(weekdayTimestamp).getUTCDay() !== components.weekday) {
return undefined;
}
return timestamp;
return { timestampMs: timestamp, leapSecond };
}
function parseHttpDateCalendarMs(

View file

@ -11,11 +11,56 @@ const modifiedAt = new Date("2024-01-01T00:00:00.000Z");
const lastModified = modifiedAt.toUTCString();
const laterModifiedSince = new Date("2024-01-02T00:00:00.000Z").toUTCString();
const earlierModifiedSince = new Date("2023-12-31T00:00:00.000Z").toUTCString();
const beforeLeapSecondAsset = {
filename: "leap-before.js",
modifiedAt: new Date("2016-12-31T23:59:59.000Z"),
body: Buffer.from('console.log("before leap second");\n'),
};
const afterLeapSecondAsset = {
filename: "leap-after.js",
modifiedAt: new Date("2017-01-01T00:00:00.000Z"),
body: Buffer.from('console.log("after leap second");\n'),
};
const leapSecondDates = [
{ name: "IMF-fixdate", value: "Sat, 31 Dec 2016 23:59:60 GMT" },
{ name: "RFC 850", value: "Saturday, 31-Dec-16 23:59:60 GMT" },
{ name: "asctime", value: "Sat Dec 31 23:59:60 2016" },
];
const conditionalCases: {
name: string;
headers: Record<string, string>;
headers: Record<string, string | string[]>;
status: 200 | 304;
}[] = [
{
name: "HTTP-date RFC850 matching instant",
headers: { "If-Modified-Since": "Monday, 01-Jan-24 00:00:00 GMT" },
status: 304,
},
{
name: "HTTP-date asctime older UTC instant",
headers: { "If-Modified-Since": "Sun Dec 31 20:00:00 2023" },
status: 200,
},
{
name: "HTTP-date asctime matching UTC instant",
headers: { "If-Modified-Since": "Mon Jan 1 00:00:00 2024" },
status: 304,
},
{
name: "HTTP-date rejects ISO timestamp",
headers: { "If-Modified-Since": "2024-01-02T00:00:00.000Z" },
status: 200,
},
{
name: "HTTP-date rejects duplicate fields with later date first",
headers: { "If-Modified-Since": [laterModifiedSince, earlierModifiedSince] },
status: 200,
},
{
name: "HTTP-date rejects duplicate fields with earlier date first",
headers: { "If-Modified-Since": [earlierModifiedSince, laterModifiedSince] },
status: 200,
},
{ name: "unconditional request", headers: {}, status: 200 },
{
name: "equal If-Modified-Since",
@ -69,7 +114,7 @@ const conditionalCases: {
function requestAsset(
url: string,
method: "GET" | "HEAD",
headers: Record<string, string>,
headers: Record<string, string | string[]>,
): Promise<{ response: IncomingMessage; body: Buffer }> {
// Node HTTP preserves an explicitly empty If-None-Match on the wire.
return new Promise((resolve, reject) => {
@ -101,6 +146,11 @@ describe.each([
await fs.writeFile(assetPath, assetBody);
await fs.writeFile(`${assetPath}.gz`, gzipSync(assetBody));
await fs.utimes(assetPath, modifiedAt, modifiedAt);
for (const asset of [beforeLeapSecondAsset, afterLeapSecondAsset]) {
const target = path.join(root, "assets", asset.filename);
await fs.writeFile(target, asset.body);
await fs.utimes(target, asset.modifiedAt, asset.modifiedAt);
}
for (const publicAsset of [
"themes/absolutely.css",
"fonts/test.css",
@ -123,6 +173,10 @@ describe.each([
`<html data-openclaw-control-ui-build-id="source-build"><head><link rel="icon" href="./favicon.svg"><link rel="icon" href="${basePath}/favicon-32.png"></head></html>`,
);
server = createServer((req, res) => {
res.setHeader(
"X-Test-If-Modified-Since-Count",
String(req.headersDistinct["if-modified-since"]?.length ?? 0),
);
void handleControlUiHttpRequest(req, res, {
basePath,
config: {},
@ -223,7 +277,10 @@ describe.each([
it.each(conditionalCases)("$name", async ({ headers, status }) => {
const { response, body } = await requestAsset(assetUrl, method, headers);
expect(response.statusCode).toBe(status);
if (Array.isArray(headers["If-Modified-Since"])) {
expect(response.headers["x-test-if-modified-since-count"]).toBe("2");
}
expect(response.statusCode, `TZ=${process.env.TZ ?? "system"}`).toBe(status);
expect(response.headers["last-modified"]).toBe(lastModified);
expect(response.headers["cache-control"]).toBe(cacheControl);
expect(response.headers.vary).toBe("Accept-Encoding");
@ -234,6 +291,36 @@ describe.each([
expect(body).toEqual(status === 200 && method === "GET" ? assetBody : Buffer.alloc(0));
});
it.each([
...leapSecondDates.flatMap(({ name, value }) => [
{ name: `${name} before midnight`, value, asset: afterLeapSecondAsset, status: 200 },
{
name: `${name} after the prior second`,
value,
asset: beforeLeapSecondAsset,
status: 304,
},
]),
{
name: "the following midnight equality",
value: "Sun, 01 Jan 2017 00:00:00 GMT",
asset: afterLeapSecondAsset,
status: 304,
},
])("preserves leap second ordering for $name", async ({ value, asset, status }) => {
const { response, body } = await requestAsset(`${baseUrl}/assets/${asset.filename}`, method, {
"If-Modified-Since": value,
});
expect(response.statusCode).toBe(status);
expect(response.headers["last-modified"]).toBe(asset.modifiedAt.toUTCString());
expect(response.headers["cache-control"]).toBe(cacheControl);
expect(response.headers["content-length"]).toBe(
status === 304 ? undefined : String(asset.body.length),
);
expect(body).toEqual(status === 200 && method === "GET" ? asset.body : Buffer.alloc(0));
});
it.each<Record<string, string>>([
{ "If-Modified-Since": laterModifiedSince },
{ "If-None-Match": "*" },

View file

@ -6,6 +6,7 @@ import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
import { pruneMapToMaxSize } from "../infra/map-size.js";
import { getOrCreatePromise } from "../shared/lazy-promise.js";
import { respondPlainText } from "./control-ui-http-utils.js";
import { matchesHttpIfModifiedSince } from "./http-conditional.js";
const CONTROL_UI_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
const CONTROL_UI_HTML_COMPRESSION_CACHE_MAX_ENTRIES = 4;
@ -184,7 +185,11 @@ function setControlUiFileHeaders(
}
/** Revalidate no-cache static assets without generating entity tags. */
export function isControlUiFileUnmodified(req: IncomingMessage, lastModifiedMs: number): boolean {
export function isControlUiFileUnmodified(
req: IncomingMessage,
lastModifiedMs: number,
nowMs = Date.now(),
): boolean {
if (req.method !== "GET" && req.method !== "HEAD") {
return false;
}
@ -193,9 +198,7 @@ export function isControlUiFileUnmodified(req: IncomingMessage, lastModifiedMs:
if (ifNoneMatch !== undefined) {
return ifNoneMatch.trim() === "*";
}
const header = req.headers?.["if-modified-since"];
const since = typeof header === "string" ? Date.parse(header) : Number.NaN;
return Number.isFinite(since) && Math.floor(lastModifiedMs / 1000) * 1000 <= since;
return matchesHttpIfModifiedSince(req, lastModifiedMs, nowMs);
}
export function respondControlUiNotModified(

View file

@ -33,7 +33,12 @@ describe("handleControlUiHttpRequest prepared root lifecycle", () => {
await fs.link(path.join(assetsDir, "app.js"), path.join(assetsDir, "app.hl.js"));
const { res, end } = makeMockHttpResponse();
const handled = await handleControlUiHttpRequest(
{ url: "/assets/app.hl.js", method: "GET" } as IncomingMessage,
{
url: "/assets/app.hl.js",
method: "GET",
headers: {},
headersDistinct: {},
} as IncomingMessage,
res,
{ root: { kind: "bundled", path: tmp, realPath: await fs.realpath(tmp) } },
);

View file

@ -212,7 +212,17 @@ describe("handleControlUiHttpRequest", () => {
}) {
const { res, end, setHeader } = makeMockHttpResponse();
const handled = await handleControlUiHttpRequest(
{ url: params.url, method: params.method, headers: params.headers ?? {} } as IncomingMessage,
{
url: params.url,
method: params.method,
headers: params.headers ?? {},
headersDistinct: Object.fromEntries(
Object.entries(params.headers ?? {}).map(([name, value]) => [
name,
Array.isArray(value) ? value : [String(value)],
]),
),
} as IncomingMessage,
res,
{
...(params.basePath ? { basePath: params.basePath } : {}),

View file

@ -1241,7 +1241,8 @@ export async function handleControlUiHttpRequest(
) {
// Future filesystem clocks must not make later replacements look unmodified;
// clamp to response origination as in resolveByteResponse.
const lastModifiedMs = Math.floor(Math.min(safeFile.mtimeMs, Date.now()) / 1_000) * 1_000;
const originatedAtMs = Date.now();
const lastModifiedMs = Math.floor(Math.min(safeFile.mtimeMs, originatedAtMs) / 1_000) * 1_000;
const representation = resolveOpenedControlUiRepresentation({
req,
sourceFile: safeFile,
@ -1255,7 +1256,7 @@ export async function handleControlUiHttpRequest(
return true;
}
// Negotiation failures precede preconditions; release the selected representation on 304.
if (isControlUiFileUnmodified(req, lastModifiedMs)) {
if (isControlUiFileUnmodified(req, lastModifiedMs, originatedAtMs)) {
fs.closeSync(representation.bodyFile.fd);
respondControlUiNotModified(res, { immutable: immutableAsset, lastModifiedMs });
return true;

View file

@ -282,27 +282,50 @@ describe("resolveByteResponse", () => {
).toMatchObject({ kind: "not-modified", statusCode: 304 });
});
it("includes a leap second when applying the RFC 850 rolling-year boundary", () => {
it.each([
{ name: "the prior second", mtimeMs: Date.UTC(1976, 11, 31, 23, 59, 59), statusCode: 304 },
{ name: "the following midnight", mtimeMs: Date.UTC(1977, 0, 1), statusCode: 200 },
])("orders the RFC 850 rolling-year leap second against $name", ({ mtimeMs, statusCode }) => {
expect(
resolveByteResponse({
file: { size: 10 },
validators: createImmutableFileValidators({ size: 10, mtimeMs: Date.UTC(1977, 0, 1) }),
validators: createImmutableFileValidators({ size: 10, mtimeMs }),
nowMs: Date.UTC(2026, 11, 31, 23, 59, 59),
method: "GET",
request: createByteRequest({ "if-modified-since": "Friday, 31-Dec-76 23:59:60 GMT" }),
}),
).toMatchObject({ kind: "not-modified", statusCode: 304 });
).toMatchObject({ kind: statusCode === 304 ? "not-modified" : "full", statusCode });
});
it("accepts a valid HTTP-date leap second without JavaScript date normalization", () => {
it.each([
...[
{ name: "IMF-fixdate", value: "Sat, 31 Dec 2016 23:59:60 GMT" },
{ name: "RFC 850", value: "Saturday, 31-Dec-16 23:59:60 GMT" },
{ name: "asctime", value: "Sat Dec 31 23:59:60 2016" },
].flatMap(({ name, value }) => [
{ name: `${name} before midnight`, value, mtimeMs: Date.UTC(2017, 0, 1), statusCode: 200 },
{
name: `${name} after the prior second`,
value,
mtimeMs: Date.UTC(2016, 11, 31, 23, 59, 59),
statusCode: 304,
},
]),
{
name: "the following midnight equality",
value: "Sun, 01 Jan 2017 00:00:00 GMT",
mtimeMs: Date.UTC(2017, 0, 1),
statusCode: 304,
},
])("preserves leap second ordering for $name", ({ value, mtimeMs, statusCode }) => {
expect(
resolveByteResponse({
file: { size: 10 },
validators: createImmutableFileValidators({ size: 10, mtimeMs: Date.UTC(2017, 0, 1) }),
validators: createImmutableFileValidators({ size: 10, mtimeMs }),
method: "GET",
request: createByteRequest({ "if-modified-since": "Sat, 31 Dec 2016 23:59:60 GMT" }),
request: createByteRequest({ "if-modified-since": value }),
}),
).toMatchObject({ kind: "not-modified", statusCode: 304 });
).toMatchObject({ kind: statusCode === 304 ? "not-modified" : "full", statusCode });
});
it.each([

View file

@ -1,8 +1,7 @@
import { createHash } from "node:crypto";
import type { FileHandle } from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import { parseRetryAfterHttpDateMs } from "@openclaw/ai/internal/retry-after";
import { matchesHttpIfNoneMatch } from "./http-conditional.js";
import { matchesHttpIfModifiedSince, matchesHttpIfNoneMatch } from "./http-conditional.js";
type FileIdentity = {
size: number;
@ -102,19 +101,13 @@ export function resolveByteResponse(params: {
lastModifiedMs === undefined ? undefined : new Date(lastModifiedMs).toUTCString();
const headers = params.request?.headers;
const ifNoneMatch = headers?.["if-none-match"];
const ifModifiedSinceValues = params.request?.headersDistinct["if-modified-since"];
// Node drops duplicate singleton fields from headers; only the distinct list is authoritative.
const ifModifiedSince =
ifModifiedSinceValues?.length === 1 ? ifModifiedSinceValues[0] : undefined;
// Any If-None-Match field supersedes If-Modified-Since, even when no ETag matches.
if (
(params.method === "GET" || params.method === "HEAD") &&
(matchesHttpIfNoneMatch(ifNoneMatch, etag) ||
(ifNoneMatch === undefined &&
lastModifiedMs !== undefined &&
typeof ifModifiedSince === "string" &&
(parseRetryAfterHttpDateMs(ifModifiedSince, originatedAtMs) ?? Number.NEGATIVE_INFINITY) >=
lastModifiedMs))
matchesHttpIfModifiedSince(params.request, lastModifiedMs, originatedAtMs)))
) {
// RFC 9110 evaluates representation validators before Range or If-Range.
return { kind: "not-modified", statusCode: 304, etag, lastModified };

View file

@ -1,4 +1,25 @@
// HTTP conditional requests use weak entity-tag comparison for representation reuse.
// HTTP validators share strict date admission and weak entity-tag comparison.
import type { IncomingMessage } from "node:http";
import { parseHttpDateInstant } from "@openclaw/ai/internal/retry-after";
export function matchesHttpIfModifiedSince(
request: Pick<IncomingMessage, "headersDistinct"> | undefined,
lastModifiedMs: number,
nowMs = Date.now(),
): boolean {
// Node drops duplicate singleton fields from headers; the distinct list owns admission.
const values = request?.headersDistinct["if-modified-since"];
const value = values?.length === 1 ? values[0] : undefined;
const since = typeof value === "string" ? parseHttpDateInstant(value, nowMs) : undefined;
const modifiedSecondMs = Math.floor(lastModifiedMs / 1_000) * 1_000;
return (
since !== undefined &&
(since.leapSecond
? modifiedSecondMs < since.timestampMs
: modifiedSecondMs <= since.timestampMs)
);
}
export function matchesHttpIfNoneMatch(
header: string | string[] | undefined,
etag: string | undefined,