fix(gateway): prevent malformed Control UI encoding weights from selecting compression (#107617)

* fix(gateway): reject malformed control ui qvalues

* test(gateway): cover valid encoding qvalues

---------

Co-authored-by: Peter Steinberger <peter@steipete.me>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
mushuiyu886
2026-07-14 13:50:01 -07:00
committed by GitHub
co-authored by Peter Steinberger Peter Steinberger
parent 24fb23d96f
commit 2b6053c3f9
2 changed files with 73 additions and 1 deletions
+8 -1
View File
@@ -55,6 +55,7 @@ type ControlUiContentEncoding = "br" | "gzip";
type ControlUiEncodingSelection = ControlUiContentEncoding | "identity" | "not-acceptable";
const CONTROL_UI_DYNAMIC_ENCODINGS = new Set<ControlUiContentEncoding>(["br", "gzip"]);
const CONTROL_UI_QVALUE_PATTERN = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
const controlUiHtmlCompressionCache = new Map<string, Promise<Buffer>>();
function contentTypeForExtension(ext: string): string {
@@ -109,7 +110,13 @@ function resolveControlUiContentEncoding(
continue;
}
const qualityParam = rawParams.find((param) => param.trim().toLowerCase().startsWith("q="));
const parsedQuality = qualityParam ? Number.parseFloat(qualityParam.trim().slice(2)) : 1;
const qualityText = qualityParam?.trim().slice(2);
const parsedQuality =
qualityText === undefined
? 1
: CONTROL_UI_QVALUE_PATTERN.test(qualityText)
? Number(qualityText)
: Number.NaN;
const quality =
Number.isFinite(parsedQuality) && parsedQuality >= 0 && parsedQuality <= 1
? parsedQuality
+65
View File
@@ -1903,6 +1903,71 @@ describe("handleControlUiHttpRequest", () => {
});
});
it("accepts RFC qvalue boundary forms", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const source = "console.log('valid-qvalue');\n".repeat(200);
const { filePath } = await writeAssetFile(tmp, "app-QvAl5678.js", source);
await fs.writeFile(`${filePath}.br`, brotliCompressSync(source));
await fs.writeFile(`${filePath}.gz`, gzipSync(source));
const cases = [
{ quality: "0", fallbackQuality: "0.5", expected: "gzip" },
{ quality: "0.", fallbackQuality: "0.5", expected: "gzip" },
{ quality: "0.000", fallbackQuality: "0.5", expected: "gzip" },
{ quality: "0.123", fallbackQuality: "0.1", expected: "br" },
{ quality: "0.999", fallbackQuality: "0.5", expected: "br" },
{ quality: "1", fallbackQuality: "0.5", expected: "br" },
{ quality: "1.", fallbackQuality: "0.5", expected: "br" },
{ quality: "1.000", fallbackQuality: "0.5", expected: "br" },
] as const;
for (const testCase of cases) {
const { end, setHeader } = await runControlUiRequest({
url: "/assets/app-QvAl5678.js",
method: "GET",
rootPath: tmp,
rootKind: "bundled",
headers: {
"accept-encoding": `br;q=${testCase.quality}, gzip;q=${testCase.fallbackQuality}, identity;q=0`,
},
});
expect(setHeader).toHaveBeenCalledWith("Content-Encoding", testCase.expected);
const compressed = end.mock.calls[0]?.[0] as Buffer;
const decoded =
testCase.expected === "br" ? brotliDecompressSync(compressed) : gunzipSync(compressed);
expect(decoded.toString()).toBe(source);
}
},
});
});
it("rejects malformed Accept-Encoding qvalues instead of parsing numeric prefixes", async () => {
await withControlUiRoot({
fn: async (tmp) => {
const source = "console.log('strict-qvalue');\n".repeat(200);
const { filePath } = await writeAssetFile(tmp, "app-QvAl1234.js", source);
await fs.writeFile(`${filePath}.br`, brotliCompressSync(source));
await fs.writeFile(`${filePath}.gz`, gzipSync(source));
for (const malformedQuality of ["0.8junk", ".8", "0.1234", "1.001", "1e0"]) {
const { end, setHeader } = await runControlUiRequest({
url: "/assets/app-QvAl1234.js",
method: "GET",
rootPath: tmp,
rootKind: "bundled",
headers: {
"accept-encoding": `br;q=${malformedQuality}, gzip;q=0.5, identity;q=0`,
},
});
expect(setHeader).toHaveBeenCalledWith("Content-Encoding", "gzip");
expect(gunzipSync(end.mock.calls[0]?.[0] as Buffer).toString()).toBe(source);
}
},
});
});
it("falls through to an acceptable sidecar when the preferred variant is missing", async () => {
await withControlUiRoot({
fn: async (tmp) => {