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);
});