fix: accept only well-formed UTF-8 sequences, and validate before previewing

Addresses Copilot's round-18 review on #2035. Two suppressed comments, both
holes in my own recent fixes rather than in the original code.

1. Round 16's grouping keyed off the length-announcing lead byte alone, so
   it accepted sequences RFC 3629 forbids -- and the comment claiming they
   fell back to per-triplet counting was simply wrong. Measured on 05bbfe50:

     x{+v:1}  "%C0%80x"        =>  "x%C0%80"        overlong
              "%E0%80%80x"     =>  "x%E0%80%80"     overlong
              "%ED%A0%80x"     =>  "x%ED%A0%80"     UTF-16 surrogate
              "%F4%90%80%80x"  =>  "x%F4%90%80%80"  past U+10FFFF

   The lead-byte ranges are now the well-formed ones -- C2-DF, E0 only with
   A0-BF, ED only with 80-9F, F0 only with 90-BF, F4 only with 80-8F -- so
   each of the above falls back to one triplet, which is what the fallback
   was always documented to do. The legal boundary cases (%E0%A0%80,
   %ED%9F%BF, %F4%8F%BF%BF) are tested alongside, so tightening the ranges
   cannot quietly start splitting valid characters.

2. The preview never ran the read's template validation. It expands
   expression by expression, so `x://{a}}/{b}` with both fields filled
   previewed `x://1}/2` -- a URI whose stray `}` makes every read refuse.

   Validation moved into an exported `templateError` that both callers
   consult, so they cannot drift. It subsumes the narrower rule round 12
   added here ("leave the invalid part standing"), which still let the REST
   of such a template expand: the refusal is per template, not per
   expression, so the preview now shows the template verbatim -- literals
   unencoded included, since half-normalizing an unreadable template invents
   a string the server never published. Two existing tests asserted the
   weaker behavior and now assert this one.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 01:40:50 -04:00
commit 9caac43da4
2 changed files with 130 additions and 33 deletions

View file

@ -93,10 +93,10 @@ describe("previewUriTemplate - literals", () => {
expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}");
});
it("keeps a malformed template legible rather than encoding its brace", () => {
expect(previewUriTemplate("x://café/{oops", {})).toBe(
"x://caf%C3%A9/{oops",
);
it("shows a template the read would refuse exactly as the server wrote it", () => {
// Not even the literal is encoded: an unreadable template is displayed
// verbatim rather than half-normalized into something never published.
expect(previewUriTemplate("x://café/{oops", {})).toBe("x://café/{oops");
});
});
@ -120,11 +120,18 @@ describe("previewUriTemplate - it must never throw or over-promise", () => {
);
});
it("still previews the valid expressions around an invalid one", () => {
expect(previewUriTemplate("x://{a,}/{b}", { a: "1", b: "2" })).toBe(
"x://{a,}/2",
);
});
it.each([
["an invalid varspec", "x://{a,}/{b}"],
["a stray closing brace", "x://{a}}/{b}"],
])(
"does not expand the valid expressions around %s either",
(_label, template) => {
// The refusal is per TEMPLATE, not per expression: every read of these
// is refused, so previewing `x://{a,}/2` or `x://1}/2` would promise a
// URI this form can never send. An earlier revision did exactly that.
expect(previewUriTemplate(template, { a: "1", b: "2" })).toBe(template);
},
);
});
describe("previewUriTemplate - the value ceiling", () => {

View file

@ -15,6 +15,7 @@ import {
encodeLiteral,
expandTemplateExpression,
parseUriTemplate,
templateError,
valueLengthError,
} from "@inspector/core/mcp/uriTemplate.js";
@ -25,6 +26,7 @@ export {
hasRequiredValues,
parseUriTemplate,
requiredGroups,
templateError,
templateVariables,
tryExpandUriTemplate,
unmetRequiredGroups,
@ -58,9 +60,13 @@ export type {
* expand-the-whole-template implementation got for free from the lenient
* `expandUriTemplate` it called:
*
* - **An invalid expression is left standing.** `{a,}` still parses `a` into
* its varspecs, so filling `a` would preview `x://1` for a template whose
* submission is refused outright -- a URI the user could never send.
* - **A template the read would refuse is shown verbatim.** `{a,}` still
* parses `a` into its varspecs, and `x://{a}}/{b}` parses two perfectly good
* expressions around a stray `}`, so expanding either previewed a URI whose
* submission is refused outright. `templateError` is the read's own verdict,
* so consulting it first is what keeps the two from disagreeing -- and it
* subsumes the narrower "skip the invalid part" rule this had at first,
* which left the *rest* of such a template expanding.
* - **The whole thing is wrapped.** This runs during render, where a throw
* unmounts the panel instead of disabling its button, and encoding can throw
* for real input: `encodeURIComponent` raises `URIError` on an unpaired
@ -79,11 +85,16 @@ export function previewUriTemplate(
// paste froze the UI anyway. Checked before any encoding happens.
if (valueLengthError(defined) !== null) return uriTemplate;
// The same verdict the read is gated on. Without it this expanded expression
// by expression regardless: `x://{a}}/{b}` with both fields filled previewed
// `x://1}/2`, a URI whose stray `}` makes every read refuse -- the preview
// promising something the form it sits in can never send.
if (templateError(uriTemplate) !== null) return uriTemplate;
try {
return parseUriTemplate(uriTemplate)
.map((part) => {
if (part.kind === "literal") return encodeLiteral(part.text);
if (part.invalid) return part.source;
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are
// valid RFC 6570 variable names, and a plain lookup would find
// `Object.prototype`'s member and treat a blank field as filled.