diff --git a/app/api/integrations/github/webhook/route.ts b/app/api/integrations/github/webhook/route.ts index 7ce409d9b..969dd893a 100644 --- a/app/api/integrations/github/webhook/route.ts +++ b/app/api/integrations/github/webhook/route.ts @@ -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"; @@ -66,23 +63,41 @@ 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", @@ -90,6 +105,7 @@ export async function POST(request: NextRequest) { }); } 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 }); } @@ -97,43 +113,15 @@ export async function POST(request: NextRequest) { 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); @@ -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; @@ -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 }, + ); } diff --git a/app/api/users/profile/route.ts b/app/api/users/profile/route.ts index 3acd6f186..22817030c 100644 --- a/app/api/users/profile/route.ts +++ b/app/api/users/profile/route.ts @@ -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 = [ @@ -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 } + ); + } updateData.image = avatar; } } diff --git a/lib/services/analysisJobService.ts b/lib/services/analysisJobService.ts index 65eed8f7b..9f193f29e 100644 --- a/lib/services/analysisJobService.ts +++ b/lib/services/analysisJobService.ts @@ -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 ( diff --git a/lib/utils/ssrfValidator.ts b/lib/utils/ssrfValidator.ts index 13f3e7601..527e772a9 100644 --- a/lib/utils/ssrfValidator.ts +++ b/lib/utils/ssrfValidator.ts @@ -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; + } + } + + // Standard IPv4 regex parsing const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); if (ipv4Match) { const parts = [ diff --git a/src/components/repository/RepositoryInsights.tsx b/src/components/repository/RepositoryInsights.tsx index 3e0854e94..f5351026d 100644 --- a/src/components/repository/RepositoryInsights.tsx +++ b/src/components/repository/RepositoryInsights.tsx @@ -5,7 +5,7 @@ import { CodeMetrics } from './CodeMetrics' 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'