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 ResponseByteLimitError extends Error {}
|
||||||
|
export class ResponseTextDecodeError extends Error {}
|
||||||
|
|
||||||
function normalizePath(path: string): string {
|
function normalizePath(path: string): string {
|
||||||
if (!path || path === "/") return "";
|
if (!path || path === "/") return "";
|
||||||
@@ -198,7 +199,11 @@ export async function readBoundedText(
|
|||||||
bytes.set(chunk, offset);
|
bytes.set(chunk, offset);
|
||||||
offset += chunk.byteLength;
|
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>(
|
async function requireJson<T>(
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export function failureNotice<D>(
|
|||||||
// 同義wrapperをtitle/detail/explanationの三重表示にしない。
|
// 同義wrapperをtitle/detail/explanationの三重表示にしない。
|
||||||
supplements: [
|
supplements: [
|
||||||
...new Set(failure.context.diagnostics.map((item) => item.explanation)),
|
...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),
|
].filter((text) => text !== failure.explanation),
|
||||||
compensation: [],
|
compensation: [],
|
||||||
details,
|
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" } },
|
{ 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);
|
const received = await receiveProblem(response, fixtureDecoder);
|
||||||
|
equal(received.kind, ["http", "invalid", "transport"][index]);
|
||||||
if (received.kind === "problem") throw new Error("unexpected problem");
|
if (received.kind === "problem") throw new Error("unexpected problem");
|
||||||
equal(JSON.stringify(failureNotice(received)).includes("secret"), false);
|
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 {
|
import {
|
||||||
readBoundedText,
|
readBoundedText,
|
||||||
ResponseByteLimitError,
|
ResponseByteLimitError,
|
||||||
|
ResponseTextDecodeError,
|
||||||
} from "#lib/workspace/api/http.ts";
|
} from "#lib/workspace/api/http.ts";
|
||||||
|
|
||||||
// domain固有detailsの検証は公開schemaを所有するadapterへ渡す。
|
// domain固有detailsの検証は公開schemaを所有するadapterへ渡す。
|
||||||
@@ -117,13 +118,16 @@ function parseBounded(text: string): unknown {
|
|||||||
if (text[i] === "\\") i++;
|
if (text[i] === "\\") i++;
|
||||||
else if (text[i] === '"') break;
|
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;
|
let next = i + 1;
|
||||||
while (/\s/u.test(text[next] ?? "") && next < text.length) next++;
|
while (/\s/u.test(text[next] ?? "") && next < text.length) next++;
|
||||||
if (text[next] === ":") {
|
if (text[next] === ":") {
|
||||||
const key: unknown = JSON.parse(text.slice(start, i + 1));
|
|
||||||
const keys = stack.at(-1);
|
const keys = stack.at(-1);
|
||||||
if (!keys || typeof key !== "string" || keys.has(key)) return invalid();
|
if (!keys || keys.has(token)) return invalid();
|
||||||
keys.add(key);
|
keys.add(token);
|
||||||
}
|
}
|
||||||
} else if (char === "{" || char === "[") {
|
} else if (char === "{" || char === "[") {
|
||||||
stack.push(char === "{" ? new Set<string>() : null);
|
stack.push(char === "{" ? new Set<string>() : null);
|
||||||
@@ -197,12 +201,49 @@ function decodeEnvelope<D>(value: unknown, decode: DetailsDecoder<D>):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uriReference(value: unknown): string {
|
function uriReference(value: unknown): string {
|
||||||
// URIは表示/取得先として使わない。WHATWG URLの補正も行わない。
|
// RFC 3986のcomponentを検査するだけで、base解決/取得/正規化はしない。
|
||||||
if (
|
if (
|
||||||
typeof value !== "string" ||
|
typeof value !== "string" ||
|
||||||
utf8.encode(value).length > FAILURE_LIMITS.explanationBytes ||
|
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();
|
) 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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +307,7 @@ export async function receiveProblem<D>(
|
|||||||
return {
|
return {
|
||||||
kind: error instanceof ResponseByteLimitError
|
kind: error instanceof ResponseByteLimitError
|
||||||
? "too_large"
|
? "too_large"
|
||||||
: error instanceof TypeError
|
: error instanceof ResponseTextDecodeError
|
||||||
? "invalid"
|
? "invalid"
|
||||||
: "transport",
|
: "transport",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user