From b86d4a0f870f2f76e36427599eb02d9782e92a1d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 15 Sep 2026 23:03:10 -0700 Subject: [PATCH] fix(telemetry): discard malformed UTF-8 uploads --- CHANGELOG.md | 1 + README.md | 4 ++-- src/feature-stats.ts | 6 ++++- test/feature-stats.test.ts | 34 ++++++++++++++++++++++++++++ test/latest-version-runtime.test.mjs | 20 ++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be6addb..ea639e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Discard malformed UTF-8 feature-statistics uploads instead of repairing and recording them, while preserving update responses. - Keep offline exports outside their executing source checkout, linked worktrees, and input archives, including differently cased paths on case-insensitive filesystems. - Remove the public statistics dashboard and `/api/stats` endpoint while preserving the privacy page, update checks, analytics recording, and data retention. - Add a bounded, manual Worker-health CLI with hourly adaptive request and error estimates, strict incomplete-data handling, and an operator runbook. No client collection or Worker runtime settings change. diff --git a/README.md b/README.md index 65b1787..b2817c0 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ carries a small JSON body: Interactive setup defaults to **No thanks**; guided Quick Start skips that prompt. Scripted installs do not opt in automatically. The enabled setting, not a recorded prompt response, controls inclusion. The server limits bodies containing anonymous feature statistics to 16 KiB while reading -the upload. Oversized or malformed bodies are discarded, and the request still receives its -version answer. +the upload. Oversized or malformed bodies, including invalid UTF-8, are discarded, and the request +still receives its version answer. diff --git a/src/feature-stats.ts b/src/feature-stats.ts index 03a960f..20a901b 100644 --- a/src/feature-stats.ts +++ b/src/feature-stats.ts @@ -44,7 +44,11 @@ async function readCappedText(request: Request): Promise { bytes.set(chunk, offset); offset += chunk.byteLength; } - return new TextDecoder().decode(bytes); + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); + } catch { + return undefined; + } } export async function readFeatureStats(request: Request) { diff --git a/test/feature-stats.test.ts b/test/feature-stats.test.ts index 2c6ae00..02d61a6 100644 --- a/test/feature-stats.test.ts +++ b/test/feature-stats.test.ts @@ -54,6 +54,40 @@ describe("readFeatureStats", () => { }); }); + it.each([ + { label: "invalid leading byte", invalid: [0xff] }, + { label: "overlong encoding", invalid: [0xc0, 0xaf] }, + { label: "incomplete sequence", invalid: [0xe2, 0x82] }, + { label: "encoded surrogate", invalid: [0xed, 0xa0, 0x80] }, + ])("discards malformed UTF-8 without repairing the payload: $label", async ({ invalid }) => { + const encoder = new TextEncoder(); + const body = new Uint8Array([ + ...encoder.encode(FEATURE_BODY.slice(0, -1) + ',"ignored":"'), + ...invalid, + ...encoder.encode('"}'), + ]); + const request = new Request("https://telemetry.example/api/latest-version", { + method: "POST", + body, + }); + await expect(readFeatureStats(request)).resolves.toBeUndefined(); + }); + + it("accepts valid UTF-8 split across upload chunks", async () => { + const bytes = new TextEncoder().encode(FEATURE_BODY.slice(0, -1) + ',"ignored":"東京�"}'); + const body = new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); + controller.close(); + }, + }); + await expect(readFeatureStats(postStream(body))).resolves.toMatchObject({ + channels: ["telegram"], + pluginsEnabled: 1, + sessionsLast24h: 2, + }); + }); + it("rejects a huge body with no Content-Length before reading the whole stream", async () => { const chunkSize = 4_096; const totalBytes = 1_048_576; diff --git a/test/latest-version-runtime.test.mjs b/test/latest-version-runtime.test.mjs index 18eb32b..4fa429a 100644 --- a/test/latest-version-runtime.test.mjs +++ b/test/latest-version-runtime.test.mjs @@ -117,4 +117,24 @@ describe("update checks over workerd HTTP", () => { expect(update.status).toBe(200); await expect(update.json()).resolves.toEqual({ version: "2026.8.2" }); }, 30_000); + + it("drops malformed UTF-8 feature bodies while serving updates over HTTP", async () => { + await start(recordingScript); + const origin = await runtime.ready; + const response = await fetch(new URL("/api/latest-version", origin), { + method: "POST", + headers: { "content-type": "application/json" }, + body: Buffer.concat([ + Buffer.from('{"schema":1,"features":{"plugins":["codex"],"pluginsEnabled":7},"ignored":"'), + Buffer.from([0xff]), + Buffer.from('"}'), + ]), + }); + expect(response.status).toBe(200); + const result = await response.json(); + expect(result.status).toBe(200); + expect(result.body).toEqual({ version: "2026.8.2" }); + expect(result.point.doubles).toEqual([0, 0, 0]); + expect(result.point.blobs.slice(5, 8)).toEqual(["", "", ""]); + }, 30_000); });