Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<a id="cloudflare-derived-request-geography"></a>

Expand Down
6 changes: 5 additions & 1 deletion src/feature-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ async function readCappedText(request: Request): Promise<string | undefined> {
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) {
Expand Down
34 changes: 34 additions & 0 deletions test/feature-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>({
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;
Expand Down
20 changes: 20 additions & 0 deletions test/latest-version-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});