fix(web): validate problem references and distinguish read failures
This commit is contained in:
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user