diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index c97221022..996d7059f 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -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( diff --git a/web/workspace/src/lib/workspace/failure/model.ts b/web/workspace/src/lib/workspace/failure/model.ts index 6165e4ff4..9e6a17a52 100644 --- a/web/workspace/src/lib/workspace/failure/model.ts +++ b/web/workspace/src/lib/workspace/failure/model.ts @@ -49,6 +49,9 @@ export function failureNotice( // 同義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, diff --git a/web/workspace/src/lib/workspace/failure/receive.test.ts b/web/workspace/src/lib/workspace/failure/receive.test.ts index 36f3204d6..b48f44bf0 100644 --- a/web/workspace/src/lib/workspace/failure/receive.test.ts +++ b/web/workspace/src/lib/workspace/failure/receive.test.ts @@ -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", + ); +}); diff --git a/web/workspace/src/lib/workspace/failure/receive.ts b/web/workspace/src/lib/workspace/failure/receive.ts index 58c4d4e30..e921964ca 100644 --- a/web/workspace/src/lib/workspace/failure/receive.ts +++ b/web/workspace/src/lib/workspace/failure/receive.ts @@ -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() : null); @@ -197,12 +201,49 @@ function decodeEnvelope(value: unknown, decode: DetailsDecoder): } 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( return { kind: error instanceof ResponseByteLimitError ? "too_large" - : error instanceof TypeError + : error instanceof ResponseTextDecodeError ? "invalid" : "transport", };