Skip to content
Open
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
163 changes: 54 additions & 109 deletions app/api/integrations/github/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { verifyGitHubWebhookSignature } from "@/lib/utils/githubWebhook";
import { GithubWebhookVerifier } from "@/lib/services/githubWebhookVerifier";
import prisma from "@/lib/prisma";
import crypto from "crypto";
import { getClientIp } from "@/lib/services/rateLimitService";
import { SafeHttpClient } from "@/services/security/safe-http-client";
import { webhookQueue } from "@/lib/services/webhook-queue";
import { dbHealthService } from "@/lib/services/db-health";
import { webhookRetryService } from "@/lib/services/webhook-retry";
import { checkRateLimit, rateLimitResponse, RATE_LIMITS } from "@/lib/middleware/rateLimit";
import { generateWebhookKey, tryAcquireIdempotency, releaseIdempotency } from "@/lib/utils/idempotency";
import { generateWebhookKey, tryAcquireIdempotency } from "@/lib/utils/idempotency";

export const runtime = "nodejs";

Expand Down Expand Up @@ -66,74 +63,65 @@ function shouldHandleIssueAction(action: string | undefined): boolean {
}

export async function POST(request: NextRequest) {
const rawBody = await request.text();

/*
* Step 1: Signature verification (BEFORE rate limiting)
* Verify HMAC-SHA256 before applying rate limits. This prevents an attacker
* from exhausting the webhook rate limit for a legitimate IP using forged requests.
*/
const signature = request.headers.get("x-hub-signature-256");
const event = request.headers.get("x-github-event");
const secret = process.env.GITHUB_WEBHOOK_SECRET || "";

const isValid = await GithubWebhookVerifier.verifySignature(request, rawBody) ||
verifyGitHubWebhookSignature({
rawBody,
signature256Header: signature,
webhookSecret: secret,
});

if (!isValid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}

/*
* ┌──────────────────────────────────────────────────────────┐
* │ 1. Rate limiting │
* │ Apply per-IP rate limits before any I/O or parsing. │
* └──────────────────────────────────────────────────────────┘
* Step 2: Rate limiting
* Apply per-IP rate limits after signature is validated.
*/
const ip = getClientIp(request);
const rl = await checkRateLimit(ip, RATE_LIMITS.GITHUB_WEBHOOK);

const rawBody = await request.text();

if (rl.fallbackFailed) {
console.error("[WebhookRoute] Rate limiters completely failed. DLQing webhook.");
try {
await prisma.webhookEvent.create({
data: {
event: request.headers.get("x-github-event") || "unknown",
event: event || "unknown",
payload: rawBody,
status: "dlq",
error: "Rate limiter and fallback completely failed",
},
});
} catch (e) {
console.error("[WebhookRoute] Failed to write to DLQ!", e);
return NextResponse.json({ error: "Webhook processing failed. Please retry." }, { status: 503 });
}
return NextResponse.json({ ok: true, message: "Webhook accepted and queued to DLQ due to severe outages" }, { status: 202 });
}

if (!rl.allowed) return rateLimitResponse(rl, "Webhook rate limit exceeded");

/*
* ┌──────────────────────────────────────────────────────────┐
* │ 2. Signature verification │
* │ Validate the HMAC-SHA256 signature using the shared │
* │ webhook secret. Two verifiers are tried: the newer │
* │ GithubWebhookVerifier service, then the legacy util. │
* └──────────────────────────────────────────────────────────┘
* Step 3: Event routing
*/
const signature = request.headers.get("x-hub-signature-256");
const event = request.headers.get("x-github-event");
const secret = process.env.GITHUB_WEBHOOK_SECRET || "";

const isValid = await GithubWebhookVerifier.verifySignature(request, rawBody) || verifyGitHubWebhookSignature({
rawBody,
signature256Header: signature,
webhookSecret: secret,
});

if (!isValid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
if (event !== "pull_request" && event !== "issues" && event !== "push") {
return NextResponse.json({ ok: true, ignored: true, event }, { status: 200 });
}

/*
* ┌──────────────────────────────────────────────────────────┐
* │ 3. Event routing — only process events we handle │
* │ Unsupported event types (label, milestone, etc.) and │
* │ non-material PR/issue actions get a silent 200. │
* └──────────────────────────────────────────────────────────┘
* Step 4: Parse payload
*/
const deliveryId = request.headers.get("x-github-delivery") || "";

if (event !== "pull_request" && event !== "issues" && event !== "push") {
return NextResponse.json(
{ ok: true, ignored: true, event },
{ status: 200 },
);
}

let payload: WebhookPayload;
try {
payload = JSON.parse(rawBody);
Expand All @@ -142,51 +130,29 @@ export async function POST(request: NextRequest) {
}

const action = payload.action;

if (event === "pull_request") {
if (!shouldHandlePullRequestAction(action)) {
return NextResponse.json(
{ ok: true, ignored: true, action },
{ status: 200 },
);
return NextResponse.json({ ok: true, ignored: true, action }, { status: 200 });
}
if (payload.pull_request?.draft && action !== "ready_for_review") {
return NextResponse.json(
{ ok: true, ignored: true, reason: "draft" },
{ status: 200 },
);
return NextResponse.json({ ok: true, ignored: true, reason: "draft" }, { status: 200 });
}
} else if (event === "issues") {
if (!shouldHandleIssueAction(action)) {
return NextResponse.json(
{ ok: true, ignored: true, action },
{ status: 200 },
);
return NextResponse.json({ ok: true, ignored: true, action }, { status: 200 });
}
} else if (event === "push") {
// We accept all push events — no action filtering needed
}

/*
* ┌──────────────────────────────────────────────────────────┐
* │ 4. Bot filtering │
* │ Ignore events sent by GitHub bots (including our own │
* │ automation) to prevent feedback loops. │
* └──────────────────────────────────────────────────────────┘
* Step 5: Bot filtering
*/
if (payload.sender?.type === "Bot") {
return NextResponse.json(
{ ok: true, ignored: true, reason: "bot" },
{ status: 200 },
);
return NextResponse.json({ ok: true, ignored: true, reason: "bot" }, { status: 200 });
}

/*
* ┌──────────────────────────────────────────────────────────┐
* │ 5. Field validation │
* │ Ensure the payload contains the minimum required │
* │ fields before proceeding to idempotency and enqueue. │
* └──────────────────────────────────────────────────────────┘
* Step 6: Field validation
*/
const owner = payload.repository?.owner?.login;
const repo = payload.repository?.name;
Expand All @@ -204,53 +170,32 @@ export async function POST(request: NextRequest) {
}

/*
* ┌──────────────────────────────────────────────────────────┐
* │ 6. Redis-based idempotency │
* │ Atomically claim the deliveryId so concurrent │
* │ deliveries of the same webhook are deduplicated. │
* │ The lock is released if the enqueue fails. │
* └──────────────────────────────────────────────────────────┘
* Step 7: Idempotency
*/
let idempotencyKey: string | null = null;
const deliveryId = request.headers.get("x-github-delivery") || "";
if (deliveryId) {
idempotencyKey = generateWebhookKey(deliveryId, event || "unknown", action);
const idempotencyKey = generateWebhookKey(deliveryId, event || "unknown", action);
const acquired = await tryAcquireIdempotency(idempotencyKey);
if (!acquired) {
return NextResponse.json(
{ ok: true, ignored: true, reason: "duplicate_delivery" },
{ status: 200 },
);
return NextResponse.json({ ok: true, ignored: true, reason: "duplicate_delivery" }, { status: 200 });
}
}

/*
* ┌──────────────────────────────────────────────────────────┐
* │ 7. Persist and enqueue │
* │ Write the event to PostgreSQL via WebhookQueueService, │
* │ which also enqueues it to BullMQ. The DB write is │
* │ synchronous within the request — no in-memory buffer. │
* │ Previously this used a global buffer + setTimeout that │
* │ was lost on serverless termination (issue #1962). │
* └──────────────────────────────────────────────────────────┘
* Step 8: Enqueue webhook
*/
const baseUrl = process.env.NEXTAUTH_URL || `http://${request.headers.get("host") || "localhost:3000"}`;

try {
const baseUrl = process.env.NEXTAUTH_URL || `http://${request.headers.get("host") || "localhost:3000"}`;
await webhookQueue.enqueueWebhook(payload, event || "unknown", action, baseUrl, deliveryId);

webhookRetryService.requeueFailedJobs().catch(() => {});

return NextResponse.json(
{ ok: true, message: "Webhook accepted and queued for processing" },
{ status: 202 }
);
} catch (error) {
console.error("Error queueing webhook event:", error);
if (idempotencyKey) {
await releaseIdempotency(idempotencyKey);
}
return NextResponse.json(
{ error: "Failed to queue webhook event" },
{ status: 500 }
);
void webhookRetryService.requeueFailedJobs();
} catch (enqueueError) {
console.error("[WebhookRoute] Failed to enqueue webhook:", enqueueError);
return NextResponse.json({ error: "Failed to process webhook" }, { status: 500 });
}

return NextResponse.json(
{ ok: true, message: "Webhook accepted and queued for processing" },
{ status: 202 },
);
}
10 changes: 10 additions & 0 deletions app/api/users/profile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
recordAttempt,
clearFailedAttempts,
} from "@/lib/services/rateLimitService";
import { validateSafeUrl } from "@/lib/utils/ssrfValidator";

const ALLOWED_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif"];
const ALLOWED_DATA_IMAGE_TYPES = [
Expand Down Expand Up @@ -371,6 +372,15 @@ export async function PUT(request: NextRequest) {

updateData.image = avatar;
} else if (avatar.startsWith("http")) {
// Validate HTTP(S) avatar URLs against SSRF to prevent probing
// internal services (e.g. AWS metadata at 169.254.169.254).
const isSafe = await validateSafeUrl(avatar);
if (!isSafe) {
return NextResponse.json(
{ error: "Avatar URL resolves to an untrusted or private network address." },
{ status: 400 }
);
Comment on lines +377 to +382

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Move this validation before any profile-update mutations.

Line 379 can return 400 after the earlier Google-account/session deleteMany calls have already committed for email-changing linked accounts. An unsafe avatar URL can therefore partially unlink/invalidate the account while the profile update fails. Run all avatar validation before those DB mutations, or make the whole update flow transactional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/users/profile/route.ts` around lines 377 - 382, The validateSafeUrl
call for the avatar URL validation is occurring after database mutations
(deleteMany calls for Google account/session management) have already been
committed, which means a failed avatar validation can leave the account in a
partially corrupted state. Move the avatar validation using validateSafeUrl to
the beginning of the profile update logic, before any database mutations are
executed, to ensure all validations pass before any data changes are persisted.

}
updateData.image = avatar;
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/services/analysisJobService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ export class AnalysisJobService {
WITH candidate AS (
SELECT a1.id
FROM analysis_jobs a1
WHERE a1.next_run_at <= NOW()
WHERE COALESCE(a1.next_run_at, NOW()) <= NOW()
AND a1.status IN ('QUEUED', 'PROCESSING')
AND (a1.lock_expires_at IS NULL OR a1.lock_expires_at < NOW())
AND NOT EXISTS (
Expand Down
26 changes: 25 additions & 1 deletion lib/utils/ssrfValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,33 @@ import * as dns from 'dns/promises';
* - ::1/128 (IPv6 Loopback)
* - fc00::/7 (IPv6 Unique Local Addresses)
* - fe80::/10 (IPv6 Link-local)
* - ::ffff:0.0.0.0/8 (IPv6-mapped IPv4 private ranges)
*/
export function isPrivateIP(ip: string): boolean {
// IPv4 regex parsing
// IPv6-mapped IPv4 address: extract the embedded IPv4 and check it.
// e.g. "::ffff:127.0.0.1" -> check 127.0.0.1 as private IPv4.
const ipv6MappedMatch = ip.match(/^::ffff:(\d+)\.(\d+)\.(\d+)\.(\d+)$/i);
if (ipv6MappedMatch) {
const parts = [
parseInt(ipv6MappedMatch[1], 10),
parseInt(ipv6MappedMatch[2], 10),
parseInt(ipv6MappedMatch[3], 10),
parseInt(ipv6MappedMatch[4], 10),
];

if (
parts[0] === 10 || // 10.0.0.0/8
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
(parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16
parts[0] === 127 || // 127.0.0.0/8
(parts[0] === 169 && parts[1] === 254) || // 169.254.0.0/16
parts[0] === 0 // 0.0.0.0/8
) {
return true;
}
}
Comment on lines +21 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Normalize all IPv6-mapped IPv4 forms before checking ranges.

Line 21 only matches compressed dotted-decimal ::ffff:a.b.c.d. Equivalent mapped forms like 0:0:0:0:0:ffff:127.0.0.1 or ::ffff:7f00:1 still fall through and are treated as public, so private/loopback IPv4 can bypass this validator. Prefer parsing/canonicalizing mapped IPv6 addresses and then reusing one IPv4 private-range predicate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/utils/ssrfValidator.ts` around lines 21 - 40, The ipv6MappedMatch regex
in the ssrfValidator function only matches the compressed dotted-decimal format
of IPv6-mapped IPv4 addresses (::ffff:a.b.c.d), but other equivalent
representations like 0:0:0:0:0:ffff:a.b.c.d or ::ffff:7f00:1 bypass this check.
Refactor the code to normalize or parse all IPv6-mapped IPv4 address formats
first to extract the underlying IPv4 address, then apply the existing
private-range checking logic (the range checks from lines 31-37) to the
extracted IPv4 address instead of only matching one specific IPv6 format. This
ensures all equivalent IPv6-mapped representations are properly validated
against the private IP ranges.


// Standard IPv4 regex parsing
const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipv4Match) {
const parts = [
Expand Down
2 changes: 1 addition & 1 deletion src/components/repository/RepositoryInsights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import RepositoryLearningPath from "./RepositoryLearningPath";
import { ErrorBoundary } from '@/components/ui/ErrorBoundary'
import RepositoryEvolutionTimeline from "./RepositoryEvolutionTimeline";
import ContributorIssueRecommendations from "./ContributorIssueRecommendations";
import ContributorIssueRecommendations from "../ContributorIssueRecommendations";
import DependencyVulnerabilityScanner from "./DependencyVulnerabilityScanner";
import CodeComplexityAnalyzer from "./CodeComplexityAnalyzer";
import { useState } from 'react'
Expand Down Expand Up @@ -260,7 +260,7 @@
<CodeMetrics repository={repository} />

{/* AI Repository Learning Path */}
<RepositoryLearningPath repository={repository} />

Check failure on line 263 in src/components/repository/RepositoryInsights.tsx

View workflow job for this annotation

GitHub Actions / Type Check

Type '{ repository: RepositoryData | undefined; }' is not assignable to type 'IntrinsicAttributes'.

{/* Repository Evolution Timeline */}
<RepositoryEvolutionTimeline />
Expand Down
Loading