From 7ed74417576449ef180c5c2130d9ca377ca54732 Mon Sep 17 00:00:00 2001 From: tmdeveloper007 Date: Tue, 23 Jun 2026 06:55:22 +0000 Subject: [PATCH 1/3] fix : SSRF IPv6 handling, profile avatar SSRF validation, analysis job null check, and webhook signature-first rate limiting - lib/utils/ssrfValidator.ts: Handle IPv6-mapped IPv4 addresses (::ffff:x.x.x.x) to prevent SSRF bypass via IPv6 notation - app/api/users/profile/route.ts: Add SSRF validation for HTTP avatar URLs to prevent probing internal services - lib/services/analysisJobService.ts: Use COALESCE(next_run_at, NOW()) to handle NULL next_run_at in job claiming SQL - app/api/integrations/github/webhook/route.ts: Move signature verification before rate limiting to prevent DoS; return 503 when DLQ write fails --- app/api/integrations/github/webhook/route.ts | 188 +++---------------- app/api/users/profile/route.ts | 10 + lib/services/analysisJobService.ts | 2 +- lib/utils/ssrfValidator.ts | 26 ++- 4 files changed, 66 insertions(+), 160 deletions(-) diff --git a/app/api/integrations/github/webhook/route.ts b/app/api/integrations/github/webhook/route.ts index 7ce409d9b..59fd78e1e 100644 --- a/app/api/integrations/github/webhook/route.ts +++ b/app/api/integrations/github/webhook/route.ts @@ -66,43 +66,12 @@ function shouldHandleIssueAction(action: string | undefined): boolean { } export async function POST(request: NextRequest) { - /* - * ┌──────────────────────────────────────────────────────────┐ - * │ 1. Rate limiting │ - * │ Apply per-IP rate limits before any I/O or parsing. │ - * └──────────────────────────────────────────────────────────┘ - */ - 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", - 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({ 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 1: Signature verification + * Verify HMAC-SHA256 BEFORE rate limiting. 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"); @@ -119,138 +88,41 @@ export async function POST(request: NextRequest) { } /* - * ┌──────────────────────────────────────────────────────────┐ - * │ 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 2: Rate limiting + * Apply per-IP rate limits after signature is validated. */ - 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); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } + const ip = getClientIp(request); + const rl = await checkRateLimit(ip, RATE_LIMITS.GITHUB_WEBHOOK); - const action = payload.action; - - if (event === "pull_request") { - if (!shouldHandlePullRequestAction(action)) { - 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 }, - ); - } - } else if (event === "issues") { - if (!shouldHandleIssueAction(action)) { - return NextResponse.json( - { ok: true, ignored: true, action }, - { status: 200 }, - ); + if (rl.fallbackFailed) { + console.error("[WebhookRoute] Rate limiters completely failed. DLQing webhook."); + try { + await prisma.webhookEvent.create({ + data: { + 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 503 so GitHub retries the webhook delivery. + return NextResponse.json({ error: "Webhook processing failed. Please retry." }, { status: 503 }); } - } else if (event === "push") { - // We accept all push events — no action filtering needed + return NextResponse.json({ ok: true, message: "Webhook accepted and queued to DLQ due to severe outages" }, { status: 202 }); } - /* - * ┌──────────────────────────────────────────────────────────┐ - * │ 4. Bot filtering │ - * │ Ignore events sent by GitHub bots (including our own │ - * │ automation) to prevent feedback loops. │ - * └──────────────────────────────────────────────────────────┘ - */ - if (payload.sender?.type === "Bot") { - return NextResponse.json( - { ok: true, ignored: true, reason: "bot" }, - { status: 200 }, - ); - } + if (!rl.allowed) return rateLimitResponse(rl, "Webhook rate limit exceeded"); /* - * ┌──────────────────────────────────────────────────────────┐ - * │ 5. Field validation │ - * │ Ensure the payload contains the minimum required │ - * │ fields before proceeding to idempotency and enqueue. │ - * └──────────────────────────────────────────────────────────┘ + * Step 3: Event routing — only process events we handle. + * Unsupported event types (label, milestone, etc.) and non-material PR/issue + * actions get a silent 200. */ - const owner = payload.repository?.owner?.login; - const repo = payload.repository?.name; - const number = payload.pull_request?.number || payload.issue?.number; - const installationId = payload.installation?.id; - - if (!owner || !repo || (!number && event !== "push") || !installationId) { - return NextResponse.json( - { - error: "Missing required fields", - details: { owner, repo, number, installationId, event }, - }, - { status: 400 }, - ); - } + const deliveryId = request.headers.get("x-github-delivery") || ""; - /* - * ┌──────────────────────────────────────────────────────────┐ - * │ 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. │ - * └──────────────────────────────────────────────────────────┘ - */ - let idempotencyKey: string | null = null; - if (deliveryId) { - 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 }, - ); - } + if (event !== "pull_request" && event !== "issues" && event !== "push") { + return NextResponse.json({ ok: true, message: "Event type not handled" }, { 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). │ - * └──────────────────────────────────────────────────────────┘ - */ - 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 } - ); - } -} 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 = [ From 638326a93327a6352e0fb6117624cb137c9cafea Mon Sep 17 00:00:00 2001 From: tmdeveloper007 Date: Tue, 23 Jun 2026 18:42:13 +0000 Subject: [PATCH 2/3] fix : correct import path for ContributorIssueRecommendations --- src/components/repository/RepositoryInsights.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From 6fcee5ac88b3eccbbb8e65137d472bff4aeadd1e Mon Sep 17 00:00:00 2001 From: tmdeveloper007 Date: Tue, 23 Jun 2026 18:56:59 +0000 Subject: [PATCH 3/3] fix : restore webhook processing logic and fix ESLint unused imports --- app/api/integrations/github/webhook/route.ts | 109 ++++++++++++++++--- 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/app/api/integrations/github/webhook/route.ts b/app/api/integrations/github/webhook/route.ts index 59fd78e1e..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"; @@ -69,19 +66,20 @@ export async function POST(request: NextRequest) { const rawBody = await request.text(); /* - * Step 1: Signature verification - * Verify HMAC-SHA256 BEFORE rate limiting. This prevents an attacker from - * exhausting the webhook rate limit for a legitimate IP using forged requests. + * 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, - }); + const isValid = await GithubWebhookVerifier.verifySignature(request, rawBody) || + verifyGitHubWebhookSignature({ + rawBody, + signature256Header: signature, + webhookSecret: secret, + }); if (!isValid) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); @@ -107,7 +105,6 @@ export async function POST(request: NextRequest) { }); } catch (e) { console.error("[WebhookRoute] Failed to write to DLQ!", e); - // Return 503 so GitHub retries the webhook delivery. 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 }); @@ -116,13 +113,89 @@ export async function POST(request: NextRequest) { if (!rl.allowed) return rateLimitResponse(rl, "Webhook rate limit exceeded"); /* - * Step 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 3: Event routing + */ + if (event !== "pull_request" && event !== "issues" && event !== "push") { + return NextResponse.json({ ok: true, ignored: true, event }, { status: 200 }); + } + + /* + * Step 4: Parse payload + */ + let payload: WebhookPayload; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const action = payload.action; + + if (event === "pull_request") { + if (!shouldHandlePullRequestAction(action)) { + 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 }); + } + } else if (event === "issues") { + if (!shouldHandleIssueAction(action)) { + return NextResponse.json({ ok: true, ignored: true, action }, { status: 200 }); + } + } + + /* + * Step 5: Bot filtering + */ + if (payload.sender?.type === "Bot") { + return NextResponse.json({ ok: true, ignored: true, reason: "bot" }, { status: 200 }); + } + + /* + * Step 6: Field validation + */ + const owner = payload.repository?.owner?.login; + const repo = payload.repository?.name; + const number = payload.pull_request?.number || payload.issue?.number; + const installationId = payload.installation?.id; + + if (!owner || !repo || (!number && event !== "push") || !installationId) { + return NextResponse.json( + { + error: "Missing required fields", + details: { owner, repo, number, installationId, event }, + }, + { status: 400 }, + ); + } + + /* + * Step 7: Idempotency */ const deliveryId = request.headers.get("x-github-delivery") || ""; + if (deliveryId) { + 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 }); + } + } - if (event !== "pull_request" && event !== "issues" && event !== "push") { - return NextResponse.json({ ok: true, message: "Event type not handled" }, { status: 200 }); + /* + * Step 8: Enqueue webhook + */ + const baseUrl = process.env.NEXTAUTH_URL || `http://${request.headers.get("host") || "localhost:3000"}`; + + try { + await webhookQueue.enqueueWebhook(payload, event || "unknown", action, baseUrl, deliveryId); + 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 }, + ); +}