Skip to content

Commit 130f460

Browse files
committed
fix(fe): stream bodies into blobs, and stop refusing legitimate bidi text
Two review points on `appMetadata.ts`. The capped reader accumulated chunks into a `Uint8Array`, so a logo of up to the cap was materialized as a JS buffer on its way to being re-encoded. It now pipes the response through a counting transform into a `Blob` instead: the bytes accumulate in the browser's blob store, only one chunk at a time is a JS buffer, and the blob goes straight to `createImageBitmap`, so the encoded image is never a JS value at all. The cap still bounds the transfer rather than just the result -- erroring the transform aborts the source stream. The metadata document still becomes a string, since 8 KiB of JSON has to be parsed. Refusing every bidi and zero-width character broke exactly the names that need them. The marks U+200E/U+200F/U+061C are how an Arabic name ending in "!" or a Hebrew name with an embedded Latin word gets its neutral characters on the right side; the isolates U+2066-U+2069 are Unicode's recommended way to embed a run of unknown direction; U+200B is a line-break opportunity in Thai and Khmer. None of them can reorder text, so all are now accepted. What is refused is the set that can: the embeddings and overrides U+202A-U+202E (plus control characters and U+FEFF as before). Isolates additionally have to be balanced, so an app's string cannot run past itself into the sentence II renders around it, and a field has to contain at least one visible character, so an all-invisible name is treated as absent instead of displayed as a blank. The app-provided description also renders with `dir="auto"` now, which resolves its direction from its own content and isolates it from the screen. The published JSON Schema follows, and notes the one rule it cannot express (isolate balance). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
1 parent 826858d commit 130f460

4 files changed

Lines changed: 180 additions & 82 deletions

File tree

docs/ii-spec.mdx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,9 @@ Requirements:
357357

358358
- All fields are optional, and unknown fields are ignored, so that fields added in the future do not invalidate documents for older versions of Internet Identity. A document that carries no field Internet Identity knows is ignored; a valid document replaces any curated fallback entry wholesale.
359359
- A field that is present but does not meet the requirements below invalidates **the whole document**, which is then ignored — the offending field is not simply dropped. An application whose file is wrong therefore sees none of its metadata applied, instead of shipping a file that is silently half-applied on a screen it does not control; Internet Identity also logs which field is at fault to the browser console.
360-
- `name` must not exceed 40 characters and `description` must not exceed 120 characters, counted in Unicode code points on the value as served. Neither may be blank, and neither may contain control characters (other than the ASCII whitespace characters `\t`, `\n`, `\v`, `\f` and `\r`) or zero-width and bidirectional formatting characters, which could otherwise make the rendered text read differently from what it contains. The zero-width joiners U+200C and U+200D are allowed, since they are needed for correct shaping in scripts such as Persian and for emoji sequences.
360+
- `name` must not exceed 40 characters and `description` must not exceed 120 characters, counted in Unicode code points on the value as served. Neither may contain control characters (other than the ASCII whitespace characters `\t`, `\n`, `\v`, `\f` and `\r`), the bidirectional embeddings and overrides U+202A–U+202E, or U+FEFF. Only these reordering controls are refused: an override makes text render in an order other than the one it is written in, which is what would let a name read as something it does not contain, and the embeddings are deprecated in favour of the isolates for the same reason.
361+
- The characters that mixed-direction and non-Latin names legitimately need are accepted: the bidirectional marks U+200E, U+200F and U+061C (zero-width hints that only affect where neutral characters such as punctuation and digits land at a direction boundary), the bidirectional isolates U+2066–U+2069, and the zero-width characters U+200B–U+200D (line-break opportunities in scripts such as Thai and Khmer, shaping in scripts such as Persian, and emoji sequences). Isolates must be **balanced**, however: a field must close every isolate it opens and close none it did not open, since an unbalanced isolate would extend past the application's own text and reorder what Internet Identity renders around it.
362+
- A field must contain at least one visible character — one that is neither whitespace nor one of the invisible characters above — so that a field which renders as nothing is treated as absent rather than displayed as a blank name.
361363
- Before being displayed, `name` and `description` have runs of whitespace collapsed to single spaces and are trimmed. This is presentation only, applied after the requirements above are checked: it never rescues a value that violates them.
362364
- `logo` must be a URL (relative URLs are resolved against the document's origin) pointing to a raster image _on that same origin_. It must be served with one of the content types `image/png`, `image/jpeg`, `image/webp`, `image/gif` or `image/avif`, must not exceed 1 MiB, and must decode to an image of at most 4096 pixels per axis. Internet Identity downloads the logo (it is never hotlinked), so both the metadata document and the logo asset must be readable cross-origin (see the CORS note below).
363365
- The logo is not rendered as served: Internet Identity decodes it, draws it once into a canvas scaled to at most 512 pixels on its longest side, and renders that re-encoding from a `blob:` URL. What is displayed is therefore an image Internet Identity produced itself — still (an animated image is flattened to its first frame), bounded in size, and held in the browser's blob store rather than in the page's DOM or its JavaScript heap. `image/svg+xml` is not accepted, because a vector image cannot be put through that step across the browsers Internet Identity supports; applications with a vector logo serve a rasterized copy of it here.
@@ -366,7 +368,7 @@ Requirements:
366368

367369
### JSON Schema {#app-metadata-schema}
368370

369-
The schema below expresses the requirements above, so a document that validates against it is one Internet Identity accepts (whitespace normalization happens after validation and is not part of it). Validating in CI is the easiest way to catch a mistake before it costs the application its metadata.
371+
The schema below expresses the requirements above, with one exception it cannot state: that bidirectional isolates must be balanced. Whitespace normalization happens after validation and is not part of it either. Validating in CI is the easiest way to catch a mistake before it costs the application its metadata.
370372

371373
```json
372374
{
@@ -380,16 +382,16 @@ The schema below expresses the requirements above, so a document that validates
380382
"type": "string",
381383
"minLength": 1,
382384
"maxLength": 40,
383-
"pattern": "\\S",
384-
"not": { "pattern": "[\\u0000-\\u0008\\u000e-\\u001f\\u007f-\\u009f\\u061c\\u200b\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069\\ufeff]" }
385+
"pattern": "[^\\s\\u061c\\u200b-\\u200f\\u2066-\\u2069]",
386+
"not": { "pattern": "[\\u0000-\\u0008\\u000e-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\ufeff]" }
385387
},
386388
"description": {
387389
"description": "Short description or tagline of the application",
388390
"type": "string",
389391
"minLength": 1,
390392
"maxLength": 120,
391-
"pattern": "\\S",
392-
"not": { "pattern": "[\\u0000-\\u0008\\u000e-\\u001f\\u007f-\\u009f\\u061c\\u200b\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069\\ufeff]" }
393+
"pattern": "[^\\s\\u061c\\u200b-\\u200f\\u2066-\\u2069]",
394+
"not": { "pattern": "[\\u0000-\\u0008\\u000e-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\ufeff]" }
393395
},
394396
"logo": {
395397
"description": "URL of the raster application logo, on the same origin as this document",

src/frontend/src/lib/components/ui/AuthorizeHeader.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@
7474
<Ellipsis text={hostname} position="middle" />
7575
</Badge>
7676
{#if metadata.description !== undefined}
77+
<!-- `dir="auto"` resolves the direction from the text itself and isolates
78+
it from the rest of the screen, so an RTL description reads correctly
79+
and can't reorder anything around it. -->
7780
<p
81+
dir="auto"
7882
class="text-text-tertiary line-clamp-2 max-w-[85%] text-center text-sm text-balance"
7983
>
8084
{metadata.description}

src/frontend/src/lib/utils/appMetadata.test.ts

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -322,21 +322,6 @@ test("should reject the whole document when a field has the wrong type", async (
322322
}
323323
});
324324

325-
test("should reject the whole document when a text field is blank", async () => {
326-
for (const body of [
327-
{ name: "", description: "An example app" },
328-
{ name: " ", description: "An example app" },
329-
{ name: "Example App", description: "\t\n" },
330-
]) {
331-
setupFetchMock(Response.json(body));
332-
333-
expect(
334-
await fetchAppMetadata(ORIGIN),
335-
JSON.stringify(body),
336-
).toBeUndefined();
337-
}
338-
});
339-
340325
test("should name the offending field when rejecting a document", async () => {
341326
// The console warning is the only signal an app's developers get, so it has
342327
// to say which field is at fault.
@@ -368,19 +353,19 @@ test("should normalize whitespace in text fields", async () => {
368353
});
369354
});
370355

371-
test("should reject documents whose text fields carry control or bidi characters", async () => {
372-
// These can visually reorder or hide parts of a name on the sign-in screen.
373-
// Rejecting is both safer than stripping and visible to the app: a name that
374-
// renders as something else than it reads is a bug in the file.
356+
test("should reject documents whose text fields carry reordering controls", async () => {
357+
// Control characters, and the bidi embeddings and overrides: the latter make
358+
// text render in an order other than the one it is written in, which is how a
359+
// name could read as something it doesn't contain.
375360
for (const char of [
376361
"\u0000", // NUL
377362
"\u0007", // BEL
378363
"\u001b", // ESC
379-
"\u061c", // arabic letter mark
380-
"\u200b", // zero-width space
381-
"\u200f", // right-to-left mark
364+
"\u202a", // left-to-right embedding
365+
"\u202b", // right-to-left embedding
366+
"\u202c", // pop directional formatting
367+
"\u202d", // left-to-right override
382368
"\u202e", // right-to-left override
383-
"\u2066", // left-to-right isolate
384369
"\ufeff", // zero-width no-break space
385370
]) {
386371
setupFetchMock(
@@ -394,6 +379,59 @@ test("should reject documents whose text fields carry control or bidi characters
394379
}
395380
});
396381

382+
test("should accept the bidi characters mixed-direction names need", async () => {
383+
// These only hint at where neutral characters land, or isolate a run; none of
384+
// them can reorder text. Refusing them would break exactly the names that
385+
// need them: RTL text with an embedded Latin word, or ending in punctuation
386+
// whose side would otherwise follow the paragraph direction.
387+
for (const name of [
388+
`\u200fשלום Example!`, // RTL mark
389+
`Example \u200eעברית`, // LTR mark
390+
`\u061cالعربية Example`, // arabic letter mark
391+
`\u2068Example\u2069 في المتجر`, // first-strong isolate, balanced
392+
`\u2066Example\u2069 و\u2067עברית\u2069`, // nested, balanced
393+
`ราคา\u200bถูก`, // zero-width space as a Thai line-break opportunity
394+
]) {
395+
setupFetchMock(Response.json({ name }));
396+
397+
expect(await fetchAppMetadata(ORIGIN), name).toEqual({ name });
398+
}
399+
});
400+
401+
test("should reject unbalanced bidi isolates", async () => {
402+
// An isolate only contains its contents while it is closed. Left open, it
403+
// runs to the end of the paragraph -- past the app's own name and into the
404+
// sentence II renders around it.
405+
for (const name of [
406+
`Example\u2066`, // opened, never closed
407+
`\u2069Example`, // closed without being opened
408+
`\u2066Example\u2069\u2069`, // one close too many
409+
`\u2068\u2067Example\u2069`, // one close too few
410+
]) {
411+
setupFetchMock(Response.json({ name, description: "An example" }));
412+
413+
expect(await fetchAppMetadata(ORIGIN), name).toBeUndefined();
414+
}
415+
});
416+
417+
test("should reject text fields with nothing visible in them", async () => {
418+
// Whitespace, bidi marks, isolate controls and zero-width characters all
419+
// render as nothing, so a field made only of those is an absent field.
420+
for (const name of [
421+
" ",
422+
"\u200b\u200b",
423+
"\u200e\u200f",
424+
` \u2066\u2069 `,
425+
]) {
426+
setupFetchMock(Response.json({ name, description: "An example" }));
427+
428+
expect(
429+
await fetchAppMetadata(ORIGIN),
430+
JSON.stringify(name),
431+
).toBeUndefined();
432+
}
433+
});
434+
397435
test("should preserve the zero-width joiners scripts and emoji need", async () => {
398436
// ZWNJ (U+200C) drives correct shaping in scripts such as Persian, and ZWJ
399437
// (U+200D) holds emoji sequences together. Neither is a control or bidi

0 commit comments

Comments
 (0)