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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
7 changes: 6 additions & 1 deletion docs/pr-review-fix-queue.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,7 +46,12 @@ Configuration:
- `check_run` — failing CI checks
- `pull_request` — merge state changes

Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`. If not set, verification is skipped (e.g., behind an API gateway).
Signature verification uses HMAC-SHA256 with `WEBHOOK_SECRET`.

**Fail-closed default:** If `WEBHOOK_SECRET` is not configured, requests are
rejected with a 503 response unless `WEBHOOK_GATEWAY_MODE` is explicitly set to
`"true"` (indicating the endpoint is behind an API gateway that handles its own
authentication and signature verification).

## Feedback classification

Expand Down
98 changes: 97 additions & 1 deletion src/app/api/pr-followup/webhook/route.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ vi.mock("@/lib/pr-followup-ingestion", async (importOriginal) => ({

import { POST } from "./route";
import { resetAuthCaches } from "@/lib/auth";
import crypto from "node:crypto";

function postRequest(body: unknown, headers: Record<string, string> = {}) {
return POST(
Expand All@@ -41,13 +42,108 @@ describe("POST /api/pr-followup/webhook", () => {
beforeEach(() => {
delete process.env.DISPATCH_AUTH_MODE;
delete process.env.WEBHOOK_SECRET;
delete process.env.WEBHOOK_GATEWAY_MODE;
// Default to gateway mode so existing tests pass without signature headers.
// Signature-specific tests below explicitly unset this.
process.env.WEBHOOK_GATEWAY_MODE = "true";
resetAuthCaches();
vi.clearAllMocks();
mocks.prFixQueueClient.mockReturnValue({});
mocks.processPrFollowupEvents.mockResolvedValue({ enqueued: 1, skipped: 0 });
});

describe("signature verification (fail-closed default)", () => {
it("rejects with 503 when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("not configured");
});

it("processes without signature when WEBHOOK_GATEWAY_MODE is true", async () => {
// WEBHOOK_GATEWAY_MODE is already "true" from beforeEach
delete process.env.WEBHOOK_SECRET;

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(200);
});

it("rejects with 401 when WEBHOOK_SECRET is set but no signature header", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Missing x-hub-signature-256");
});

it("rejects with 401 when signature is invalid", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const res = await postRequest(
{ action: "submitted", review: { state: "CHANGES_REQUESTED" } },
{
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": "sha256=invalid",
},
);

expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toContain("Invalid webhook signature");
});

it("processes successfully with valid signature", async () => {
delete process.env.WEBHOOK_GATEWAY_MODE;
process.env.WEBHOOK_SECRET = "test-secret";

const payload = { action: "submitted", review: { state: "CHANGES_REQUESTED" } };
const bodyStr = JSON.stringify(payload);
const sig =
"sha256=" + crypto.createHmac("sha256", "test-secret").update(bodyStr).digest("hex");

// Use a direct Request so the body bytes are exactly what we computed the HMAC over.
const req = new Request("http://localhost/api/pr-followup/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mockToken}`,
"x-github-event": "pull_request_review",
"x-hub-signature-256": sig,
},
body: bodyStr,
});
const res = await POST(req);

expect(res.status).toBe(200);
});
});

it("returns 401 when no auth header is present", async () => {
const res = await postRequest({}, { "x-github-event": "pull_request_review" });

Expand Down
38 changes: 27 additions & 11 deletions src/app/api/pr-followup/webhook/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,25 +20,35 @@ import { enforceRateLimit } from "@/lib/rate-limit";
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* requests are rejected (503) unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
/**
* Determine signature verification mode.
*
* - "verify": WEBHOOK_SECRET is set — verify HMAC-SHA256 signature
* - "skip": WEBHOOK_GATEWAY_MODE is "true" — skip verification (behind API gateway)
* - "reject": neither configured — fail-closed, reject all requests
*/
function getSignatureVerificationMode(): "verify" | "skip" | "reject" {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
if (secret) return "verify";
if (process.env.WEBHOOK_GATEWAY_MODE === "true") return "skip";
// Fail-closed: reject requests when neither WEBHOOK_SECRET nor WEBHOOK_GATEWAY_MODE is configured
return "reject";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
const expected = signature.slice(7);
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const computed = hmac.digest("hex");

// Constant-time comparison; timingSafeEqual requires equal-length buffers
if (computed.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(computed), Buffer.from(expected));
}

Expand DownExpand Up@@ -204,14 +214,20 @@ export async function POST(request: Request) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const sigMode = getSignatureVerificationMode();
if (sigMode === "reject") {
return errorResponse(
"Webhook signature verification is not configured. Set WEBHOOK_SECRET or enable WEBHOOK_GATEWAY_MODE.",
503,
);
}
if (sigMode === "verify") {
const webhookSecret = process.env.WEBHOOK_SECRET!;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return errorResponse("Missing x-hub-signature-256 header", 401);
}
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
return errorResponse("Invalid webhook signature", 401);
}
}
Expand Down
Loading