fix(public-api): allowlist telemetry features and keep /v1/version cacheable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 21:45:43 -04:00
1 parent b292fe37ab
commit 6f0b3b597f
2 files changed
+120 -7

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@vercel/firewall", () => ({
checkRateLimit: vi.fn<() => Promise<{ rateLimited: boolean }>>(async () => ({
rateLimited: false,
})),
}));
import app from "./app";
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
process.env.POSTHOG_API_KEY = "test-key";
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
});
afterEach(() => {
delete process.env.POSTHOG_API_KEY;
vi.restoreAllMocks();
});
function postTelemetry(body: unknown) {
return app.request("/v1/telemetry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function sentPayload() {
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
return JSON.parse(init.body as string);
}
describe("POST /v1/telemetry", () => {
test("drops injected feature keys and never lets features override core fields", async () => {
const res = await postTelemetry({
instanceId: "abc",
version: "0.2.0",
users: "1-5",
features: { imageCache: true, version: "9.9.9", $set: { a: 1 } },
});
expect(res.status).toBe(204);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const payload = sentPayload();
expect(payload.distinct_id).toBe("abc");
expect(payload.properties.version).toBe("0.2.0");
expect(payload.properties.imageCache).toBe(true);
expect(payload.properties.users).toBe("1-5");
expect(Object.keys(payload.properties)).not.toContain("$set");
});
test("forwards a real-client-shaped payload", async () => {
const res = await postTelemetry({
instanceId: "01912345-6789-7abc-8def-0123456789ab",
version: "0.2.0",
arch: "linux-x64",
users: "1-5",
titles: "100-500",
features: { imageCache: true, oidc: false, scheduledBackups: true },
});
expect(res.status).toBe(204);
const { properties } = sentPayload();
expect(properties.imageCache).toBe(true);
expect(properties.oidc).toBe(false);
expect(properties.scheduledBackups).toBe(true);
expect(properties.arch).toBe("linux-x64");
expect(properties.titles).toBe("100-500");
});
test("rejects a missing instanceId", async () => {
const res = await postTelemetry({ version: "0.2.0" });
expect(res.status).toBe(400);
expect(fetchSpy).not.toHaveBeenCalled();
});
test("rejects an oversized instanceId", async () => {
const res = await postTelemetry({ instanceId: "a".repeat(65), version: "0.2.0" });
expect(res.status).toBe(400);
});
});
describe("GET /v1/version", () => {
test("redirects query strings to the cacheable URL without calling GitHub", async () => {
const res = await app.request("/v1/version?x=1");
expect(res.status).toBe(301);
expect(res.headers.get("location")).toBe("/v1/version");
expect(fetchSpy).not.toHaveBeenCalled();
});
test("serves the latest release without a query string", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ tag_name: "v0.2.0", html_url: "https://example.com/r" }), {
status: 200,
}),
);
const res = await app.request("/v1/version");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ version: "0.2.0", release_url: "https://example.com/r" });
});
});
+18 -7
View File
@@ -19,6 +19,11 @@ app.use("*", cors());
// ─── Version Check ──────────────────────────────────────────
app.get("/v1/version", async (c) => {
// Query strings would bypass the edge cache (and spend the GitHub token's rate limit).
if (new URL(c.req.url).search) {
return c.redirect("/v1/version", 301);
}
try {
const res = await fetch(GITHUB_RELEASES_URL, {
headers: {
@@ -55,12 +60,18 @@ app.post(
zValidator(
"json",
z.object({
instanceId: z.string().min(1),
version: z.string().min(1),
arch: z.string().optional(),
users: z.union([z.number(), z.string()]).optional(),
titles: z.union([z.number(), z.string()]).optional(),
features: z.record(z.string(), z.unknown()).optional(),
instanceId: z.string().min(1).max(64),
version: z.string().min(1).max(64),
arch: z.string().max(64).optional(),
users: z.union([z.number(), z.string().max(16)]).optional(),
titles: z.union([z.number(), z.string().max(16)]).optional(),
features: z
.object({
imageCache: z.boolean().optional(),
oidc: z.boolean().optional(),
scheduledBackups: z.boolean().optional(),
})
.optional(),
}),
),
async (c) => {
@@ -80,11 +91,11 @@ app.post(
event: "instance_report",
distinct_id: body.instanceId,
properties: {
...body.features,
version: body.version,
arch: body.arch,
users: body.users,
titles: body.titles,
...body.features,
},
}),
signal: AbortSignal.timeout(10_000),