fix(web): validate problem references and distinguish read failures

This commit is contained in:
2026-11-06 07:41:47 +09:00
parent 8c71ec719a
commit 838b6cd3c3
4 changed files with 192 additions and 8 deletions
+6 -1
View File
@@ -31,6 +31,7 @@ const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
};
export class ResponseByteLimitError extends Error {}
export class ResponseTextDecodeError extends Error {}
function normalizePath(path: string): string {
if (!path || path === "/") return "";
@@ -198,7 +199,11 @@ export async function readBoundedText(
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
throw new ResponseTextDecodeError();
}
}
async function requireJson<T>(
@@ -49,6 +49,9 @@ export function failureNotice<D>(
// 同義wrapperをtitle/detail/explanationの三重表示にしない。
supplements: [
...new Set(failure.context.diagnostics.map((item) => item.explanation)),
...(failure.context.supplementary_omitted
? ["Some additional information was omitted by the server."]
: []),
].filter((text) => text !== failure.explanation),
compensation: [],
details,
@@ -248,9 +248,144 @@ Deno.test("bounded response text, unknown MIME, broken transport and UTF8 never
{ status: 502, headers: { "content-type": "application/problem+json" } },
),
];
for (const response of cases) {
for (const [index, response] of cases.entries()) {
const received = await receiveProblem(response, fixtureDecoder);
equal(received.kind, ["http", "invalid", "transport"][index]);
if (received.kind === "problem") throw new Error("unexpected problem");
equal(JSON.stringify(failureNotice(received)).includes("secret"), false);
}
});
Deno.test("Problem URI references are syntax checked without normalization, navigation or fetching", () => {
for (
const reference of [
"",
"../requests/1",
"?occurrence=1",
"#s",
"g:h",
"a/b:c",
"///path",
"https://user:pass@example.test:443/a?b#c",
"//[2001:db8::1]:443/a",
"//[::ffff:192.0.2.128]/",
"//[v1.a:b]/x",
"a%20b/%5B?x=%25&y=?/#%2f",
]
) {
const received = decode({
...problem(),
type: reference,
instance: reference,
});
if (received.kind !== "problem") {
throw new Error(`${reference}: ${received.kind}`);
}
equal(received.problem.instance, reference);
}
for (
const reference of [
"/requests/%GG",
"/requests/[invalid",
"foo%",
"//user@@host/x",
"//host:wrongport/x",
"//[::not-ipv6]/",
"//[v.a]/",
"//[vG.foo]/",
"//[v1.]/",
"//[::1]tail/",
"//[::1",
"//host]/",
"bad space",
"1invalid:rest",
":rest",
"a{b",
"a#b#c",
"a\\b",
"日本語",
"/a\n",
"?x=\0",
]
) {
equal(
decode({ ...problem(), type: reference, instance: reference }).kind,
"invalid",
);
}
});
Deno.test("all shared collection budgets and UTF8 identifiers are validated before presentation", () => {
for (
const [key, max, item] of [
["operations", 8, subject.context.operations[0]],
["causes", 4, {
domain: "subject",
reason: "operation_denied",
correlation: "safe",
}],
["diagnostics", 8, {
code: "safe",
explanation: "Additional information",
}],
] as const
) {
equal(
decode(
problem({
...subject,
context: { ...subject.context, [key]: Array(max).fill(item) },
}),
).kind,
"problem",
);
equal(
decode(
problem({
...subject,
context: { ...subject.context, [key]: Array(max + 1).fill(item) },
}),
).kind,
"invalid",
);
}
const received = decode(
problem({
...subject,
context: { ...subject.context, supplementary_omitted: true },
}),
);
equal(failureNotice(received).supplements, [
"Some additional information was omitted by the server.",
]);
equal(
decode(
problem({
...subject,
context: { ...subject.context, correlation: "あ".repeat(43) },
}),
).kind,
"invalid",
);
equal(
decodeProblem(
" ".repeat(FAILURE_LIMITS.bytes) + JSON.stringify(problem()),
409,
"application/problem+json",
fixtureDecoder,
).kind,
"too_large",
);
});
Deno.test("JSON Unicode scalars agree with the Rust wire reader rather than replacing lone surrogates", () => {
for (const explanation of ["bad\ud800", "bad\udfff"]) {
equal(decode(problem({ ...subject, explanation })).kind, "invalid");
}
equal(
decode(
problem({ ...subject, explanation: "Valid supplementary scalar: 🚀" }),
).kind,
"problem",
);
});
@@ -7,6 +7,7 @@ import type {
import {
readBoundedText,
ResponseByteLimitError,
ResponseTextDecodeError,
} from "#lib/workspace/api/http.ts";
// domain固有detailsの検証は公開schemaを所有するadapterへ渡す。
@@ -117,13 +118,16 @@ function parseBounded(text: string): unknown {
if (text[i] === "\\") i++;
else if (text[i] === '"') break;
}
const token: unknown = JSON.parse(text.slice(start, i + 1));
if (typeof token !== "string" || /[\uD800-\uDFFF]/u.test(token)) {
return invalid();
}
let next = i + 1;
while (/\s/u.test(text[next] ?? "") && next < text.length) next++;
if (text[next] === ":") {
const key: unknown = JSON.parse(text.slice(start, i + 1));
const keys = stack.at(-1);
if (!keys || typeof key !== "string" || keys.has(key)) return invalid();
keys.add(key);
if (!keys || keys.has(token)) return invalid();
keys.add(token);
}
} else if (char === "{" || char === "[") {
stack.push(char === "{" ? new Set<string>() : null);
@@ -197,12 +201,49 @@ function decodeEnvelope<D>(value: unknown, decode: DetailsDecoder<D>):
}
function uriReference(value: unknown): string {
// URIは表示/取得先として使わない。WHATWG URLの補正も行わない。
// RFC 3986のcomponentを検査するだけで、base解決/取得/正規化はしない。
if (
typeof value !== "string" ||
utf8.encode(value).length > FAILURE_LIMITS.explanationBytes ||
/[^\x21-\x7e]/u.test(value) || /%(?![0-9a-f]{2})/iu.test(value)
/%(?![0-9a-f]{2})/iu.test(value)
) return invalid();
const match =
/^(?:([a-z][a-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#([^#]*))?$/iu
.exec(value);
if (!match) return invalid();
const [, scheme, authority, path, query, fragment] = match;
const pchar = /^[a-z0-9._~!$&'()*+,;=:@%/-]*$/iu;
const queryChars = /^[a-z0-9._~!$&'()*+,;=:@%/?-]*$/iu;
if (
!pchar.test(path) || (query !== undefined && !queryChars.test(query)) ||
(fragment !== undefined && !queryChars.test(fragment))
) return invalid();
if (
scheme === undefined && authority === undefined &&
path.split("/")[0].includes(":")
) return invalid();
if (authority !== undefined) {
const parts = authority.split("@");
if (
parts.length > 2 ||
(parts.length === 2 && !/^[a-z0-9._~!$&'()*+,;=:%-]*$/iu.test(parts[0]))
) return invalid();
const hostPort = parts.at(-1)!;
if (hostPort.startsWith("[")) {
const literal = /^\[([^\]]+)\](?::[0-9]*)?$/u.exec(hostPort);
if (!literal) return invalid();
if (literal[1].startsWith("v") || literal[1].startsWith("V")) {
if (!/^v[0-9a-f]+\.[a-z0-9._~!$&'()*+,;=:-]+$/iu.test(literal[1])) {
return invalid();
}
} else {
// URLのhost validatorだけをIPv6構文に利用し、値は一切置換しない。
new URL(`http://[${literal[1]}]/`);
}
} else if (!/^[a-z0-9._~!$&'()*+,;=%-]*(?::[0-9]*)?$/iu.test(hostPort)) {
return invalid();
}
}
return value;
}
@@ -266,7 +307,7 @@ export async function receiveProblem<D>(
return {
kind: error instanceof ResponseByteLimitError
? "too_large"
: error instanceof TypeError
: error instanceof ResponseTextDecodeError
? "invalid"
: "transport",
};