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
2 changes: 1 addition & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export function createApp() {
return next();
});
app.use("*", async (c, next) => {
if (c.req.method === "OPTIONS" || c.req.path === "/health" || c.req.path === "/v1/github/webhook") return next();
if (c.req.method === "OPTIONS" || c.req.path === "/health") return next();
const limited = await enforceRateLimit(c, routeClassForPath(c.req.path));
if (limited) return limited;
return next();
Expand Down
1 change: 1 addition & 0 deletions src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export async function enforceRateLimit(c: Context<{ Bindings: Env }>, routeClass
}

export function routeClassForPath(path: string): RateLimitClass {
if (path === "/v1/github/webhook") return "strict";
if (path === "/v1/auth/session" || path === "/v1/auth/logout") return "normal";
if (path.startsWith("/v1/auth/")) return "strict";
if (
Expand Down
1 change: 1 addition & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ declare global {
AI_MAX_OUTPUT_TOKENS?: string;
ADMIN_GITHUB_LOGINS?: string;
GITHUB_WEBHOOK_SECRET: string;
GITHUB_WEBHOOK_MAX_BODY_BYTES?: string;
GITHUB_APP_PRIVATE_KEY: string;
GITHUB_APP_ID: string;
GITHUB_APP_SLUG: string;
Expand Down
39 changes: 38 additions & 1 deletion src/github/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { getWebhookEvent, recordWebhookEvent } from "../db/repositories";
import type { GitHubWebhookPayload, JobMessage } from "../types";
import { sha256Hex, verifyGitHubSignature } from "../utils/crypto";

const DEFAULT_MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;

export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promise<Response> {
const deliveryId = c.req.header("x-github-delivery") ?? null;
const eventName = c.req.header("x-github-event") ?? null;
Expand All @@ -11,7 +13,16 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis
return c.json({ error: "missing_github_headers" }, 400);
}

const rawBody = await c.req.text();
const maxBodyBytes = parsePositiveInt(c.env.GITHUB_WEBHOOK_MAX_BODY_BYTES) ?? DEFAULT_MAX_WEBHOOK_BODY_BYTES;
const contentLength = parsePositiveInt(c.req.header("content-length"));
if (contentLength !== null && contentLength > maxBodyBytes) {
return c.json({ error: "payload_too_large", maxBytes: maxBodyBytes }, 413);
}

const rawBody = await readBodyWithLimit(c.req.raw, maxBodyBytes);
if (rawBody === null) {
return c.json({ error: "payload_too_large", maxBytes: maxBodyBytes }, 413);
}
const verified = await verifyGitHubSignature(rawBody, signature, c.env.GITHUB_WEBHOOK_SECRET);
if (!verified) {
return c.json({ error: "invalid_signature" }, 401);
Expand Down Expand Up @@ -50,3 +61,29 @@ export async function handleGitHubWebhook(c: Context<{ Bindings: Env }>): Promis

return c.json({ ok: true, deliveryId, eventName, status: "queued" }, 202);
}

function parsePositiveInt(value: string | null | undefined): number | null {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}

async function readBodyWithLimit(request: Request, maxBytes: number): Promise<string | null> {
const stream = request.body;
if (!stream) return "";
const reader = stream.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > maxBytes) return null;
chunks.push(decoder.decode(value, { stream: true }));
}
chunks.push(decoder.decode());
return chunks.join("");
}
150 changes: 150 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,156 @@ describe("api routes", () => {
expect(rejected.status).toBe(401);
});

it("rejects oversized webhook payloads and rate limits repeated invalid webhook traffic", async () => {
const counters = new Map<string, number>();
const app = createApp();
const env = createTestEnv({
GITHUB_WEBHOOK_MAX_BODY_BYTES: "1024",
RATE_LIMITER: {
idFromName(name: string) {
return name as unknown as DurableObjectId;
},
get(id: DurableObjectId) {
const key = String(id);
return {
async fetch() {
const count = (counters.get(key) ?? 0) + 1;
counters.set(key, count);
if (count <= 10) return Response.json({ allowed: true, limit: 10, remaining: 10 - count, resetAt: "2099-01-01T00:00:00.000Z" });
return Response.json({ allowed: false, limit: 10, remaining: 0, retryAfterSeconds: 60, resetAt: "2099-01-01T00:00:00.000Z" }, { status: 429 });
},
} as unknown as DurableObjectStub;
},
} as DurableObjectNamespace,
});
const oversizedBody = JSON.stringify({
action: "opened",
repository: { full_name: "JSONbored/gittensory" },
blob: "x".repeat(3_000),
});
const tooLarge = await app.request(
"/v1/github/webhook",
{
method: "POST",
body: oversizedBody,
headers: {
"x-github-delivery": "oversized-1",
"x-github-event": "push",
},
},
env,
);
expect(tooLarge.status).toBe(413);
await expect(tooLarge.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1024 });

const invalidBody = JSON.stringify({ action: "opened", repository: { full_name: "JSONbored/gittensory" } });
let sawUnauthorized = false;
let sawRateLimited = false;
for (let index = 0; index < 12; index += 1) {
const response = await app.request(
"/v1/github/webhook",
{
method: "POST",
body: invalidBody,
headers: {
"x-github-delivery": `invalid-${index}`,
"x-github-event": "push",
"x-hub-signature-256": "sha256=bad",
},
},
env,
);
if (response.status === 401) sawUnauthorized = true;
if (response.status === 429) {
sawRateLimited = true;
await expect(response.json()).resolves.toMatchObject({ error: "rate_limited", routeClass: "strict" });
break;
}
}
expect(sawUnauthorized).toBe(true);
expect(sawRateLimited).toBe(true);
});

it("rejects oversized webhook requests from content-length and signed invalid JSON payloads", async () => {
const app = createApp();
const env = createTestEnv({ GITHUB_WEBHOOK_MAX_BODY_BYTES: "1024" });

const contentLengthRejected = await app.request(
"/v1/github/webhook",
{
method: "POST",
body: "{}",
headers: {
"x-github-delivery": "oversized-content-length",
"x-github-event": "push",
"content-length": "2048",
},
},
env,
);
expect(contentLengthRejected.status).toBe(413);
await expect(contentLengthRejected.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: 1024 });

const malformedBody = "{";
const malformedSignature = await signWebhook(malformedBody, env.GITHUB_WEBHOOK_SECRET);
const malformedJson = await app.request(
"/v1/github/webhook",
{
method: "POST",
body: malformedBody,
headers: {
"x-github-delivery": "invalid-json-signed",
"x-github-event": "push",
"x-hub-signature-256": malformedSignature,
},
},
env,
);
expect(malformedJson.status).toBe(400);
await expect(malformedJson.json()).resolves.toMatchObject({ error: "invalid_json" });
});

it("handles webhook size parsing fallbacks for invalid env/header values and empty request bodies", async () => {
const app = createApp();
const env = createTestEnv({
GITHUB_WEBHOOK_MAX_BODY_BYTES: "0",
});

const emptyBody = await app.request(
"/v1/github/webhook",
{
method: "POST",
headers: {
"x-github-delivery": "empty-body",
"x-github-event": "push",
"x-hub-signature-256": "sha256=bad",
},
},
env,
);
expect(emptyBody.status).toBe(401);
await expect(emptyBody.json()).resolves.toMatchObject({ error: "invalid_signature" });

const validBody = JSON.stringify({ action: "opened", repository: { full_name: "JSONbored/gittensory", name: "gittensory" } });
const validSignature = await signWebhook(validBody, env.GITHUB_WEBHOOK_SECRET);
const invalidLengthHeader = await app.request(
"/v1/github/webhook",
{
method: "POST",
body: validBody,
headers: {
"x-github-delivery": "invalid-content-length",
"x-github-event": "pull_request",
"x-hub-signature-256": validSignature,
"content-length": "not-a-number",
},
},
env,
);
expect(invalidLengthHeader.status).toBe(202);
await expect(invalidLengthHeader.json()).resolves.toMatchObject({ status: "queued", deliveryId: "invalid-content-length" });
});

it("serves deterministic signal endpoints from cached registry and GitHub metadata", async () => {
const app = createApp();
const env = createTestEnv();
Expand Down
1 change: 1 addition & 0 deletions test/unit/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ describe("private-beta auth and rate limiting", () => {
});

it("classifies rate-limit route costs", () => {
expect(routeClassForPath("/v1/github/webhook")).toBe("strict");
expect(routeClassForPath("/v1/auth/github/device/start")).toBe("strict");
expect(routeClassForPath("/v1/local/branch-analysis")).toBe("expensive");
expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive");
Expand Down
38 changes: 38 additions & 0 deletions test/unit/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { Context } from "hono";
import { handleGitHubWebhook } from "../../src/github/webhook";
import { createTestEnv } from "../helpers/d1";

describe("github webhook body reader edge cases", () => {
it("skips undefined stream chunks and still rejects invalid signatures", async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(undefined as unknown as Uint8Array);
controller.close();
},
});
const request = { body } as unknown as Request;
const env = createTestEnv();
const headers: Record<string, string> = {
"x-github-delivery": "stream-edge-case",
"x-github-event": "push",
"x-hub-signature-256": "sha256=bad",
};
const context = {
req: {
raw: request,
header(name: string) {
return headers[name.toLowerCase()] ?? null;
},
},
env,
json(payload: unknown, status?: number) {
return Response.json(payload, status === undefined ? undefined : { status });
},
} as unknown as Context<{ Bindings: Env }>;

const response = await handleGitHubWebhook(context);
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({ error: "invalid_signature" });
});
});
1 change: 1 addition & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"GITHUB_APP_ID": "3824093",
"GITHUB_APP_SLUG": "gittensory",
"GITHUB_OAUTH_CLIENT_ID": "Iv23li574mpdLo2PnVN4",
"GITHUB_WEBHOOK_MAX_BODY_BYTES": "1048576",
"GITTENSOR_UPSTREAM_REPO": "entrius/gittensor",
"GITTENSOR_UPSTREAM_REF": "test",
"GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json",
Expand Down