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
63 changes: 34 additions & 29 deletions apps/backend/src/app/api/admin/webhooks/dlq/route.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { withRole } from '@/lib/api/with-role';
import { withGitHubWebhookAuth } from '@/lib/github/github-webhook';
import { webhookDLQ } from '@/lib/webhook-dlq/dead-letter-queue';

/**
* GET /api/admin/webhooks/dlq
* List all dead-letter queue entries (admin only).
* List all dead-letter queue entries (admin only, signature-verified).
*/
export const GET = withRole('admin', async (_req: NextRequest) => {
const entries = webhookDLQ.list();
return NextResponse.json({ total: entries.length, entries });
});
export const GET = withGitHubWebhookAuth(
withRole('admin', async (_req: NextRequest) => {
const entries = webhookDLQ.list();
return NextResponse.json({ total: entries.length, entries });
})
);

/**
* POST /api/admin/webhooks/dlq/:id/reprocess
* Reprocess a single DLQ entry by id, passed in the request body.
*
* Body: { id: string }
*/
export const POST = withRole('admin', async (req: NextRequest) => {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
export const POST = withGitHubWebhookAuth(
withRole('admin', async (req: NextRequest) => {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

const id = (body as Record<string, unknown>)?.id;
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing required field: id' }, { status: 400 });
}
const id = (body as Record<string, unknown>)?.id;
if (!id || typeof id !== 'string') {
return NextResponse.json({ error: 'Missing required field: id' }, { status: 400 });
}

const entry = webhookDLQ.get(id);
if (!entry) {
return NextResponse.json({ error: 'DLQ entry not found' }, { status: 404 });
}
const entry = webhookDLQ.get(id);
if (!entry) {
return NextResponse.json({ error: 'DLQ entry not found' }, { status: 404 });
}

const result = await webhookDLQ.reprocess(id);
const result = await webhookDLQ.reprocess(id);

if (!result.success) {
return NextResponse.json(
{ error: result.error, entry: webhookDLQ.get(id) },
{ status: 422 }
);
}
if (!result.success) {
return NextResponse.json(
{ error: result.error, entry: webhookDLQ.get(id) },
{ status: 422 }
);
}

return NextResponse.json({ success: true, entry: webhookDLQ.get(id) });
});
return NextResponse.json({ success: true, entry: webhookDLQ.get(id) });
})
);
38 changes: 11 additions & 27 deletions apps/backend/src/app/api/admin/webhooks/replay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { createLogger, resolveCorrelationId, CORRELATION_ID_HEADER } from '@/lib/api/logger';
import { webhookDeliveryService } from '@/services/webhook-delivery.service';
import { githubDeliveryFetcherService } from '@/services/github-delivery-fetcher.service';
import { withGitHubWebhookAuth } from '@/lib/github/github-webhook';

/**
* GET /api/admin/webhooks/replay
Expand All @@ -15,7 +16,7 @@ import { githubDeliveryFetcherService } from '@/services/github-delivery-fetcher
* - deliveries: Array of deliveries that can be replayed
* - count: Total number of deliveries available for replay
*/
export async function GET(req: NextRequest) {
export const GET = withGitHubWebhookAuth(async (req: NextRequest) => {
const correlationId = resolveCorrelationId(req);
const log = createLogger({ correlationId, service: 'webhook-replay-admin' });

Expand Down Expand Up @@ -64,7 +65,7 @@ export async function GET(req: NextRequest) {
{ status: 500 }
);
}
}
});

/**
* POST /api/admin/webhooks/replay
Expand All @@ -81,7 +82,7 @@ export async function GET(req: NextRequest) {
* - replayed: number - Count of deliveries replayed
* - errors: Array of errors encountered during replay
*/
export async function POST(req: NextRequest) {
export const POST = withGitHubWebhookAuth(async (req: NextRequest) => {
const correlationId = resolveCorrelationId(req);
const log = createLogger({ correlationId, service: 'webhook-replay-admin' });

Expand Down Expand Up @@ -154,7 +155,6 @@ export async function POST(req: NextRequest) {
let replayedCount = 0;

for (const delivery of deliveries) {
// For missed deliveries, we need to fetch the full payload from GitHub
if (delivery.source === 'missed') {
if (!hookId) {
errors.push({
Expand All @@ -164,9 +164,6 @@ export async function POST(req: NextRequest) {
continue;
}

// Fetch delivery detail from GitHub
// Note: GitHub API uses numeric delivery ID, but we store GUID
// This is a limitation - we'd need to store the numeric ID as well
log.warn('Missed delivery replay not fully implemented', {
deliveryId: delivery.deliveryId,
reason: 'Need numeric delivery ID for GitHub API',
Expand All @@ -178,7 +175,6 @@ export async function POST(req: NextRequest) {
continue;
}

// Replay failed delivery
const result = await webhookDeliveryService.replayDelivery(delivery.deliveryId);

if (result.success) {
Expand Down Expand Up @@ -215,18 +211,12 @@ export async function POST(req: NextRequest) {
return res;
}

return NextResponse.json(
{ error: 'Invalid request' },
{ status: 400 }
);
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
} catch (error: any) {
log.error('Unexpected error during webhook replay', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
});

/**
* PUT /api/admin/webhooks/detect-missed
Expand All @@ -242,7 +232,7 @@ export async function POST(req: NextRequest) {
* - success: boolean
* - missedCount: number - Count of missed deliveries detected
*/
export async function PUT(req: NextRequest) {
export const PUT = withGitHubWebhookAuth(async (req: NextRequest) => {
const correlationId = resolveCorrelationId(req);
const log = createLogger({ correlationId, service: 'webhook-missed-detection' });

Expand All @@ -251,10 +241,7 @@ export async function PUT(req: NextRequest) {
const { hookId, lookbackHours = 24 } = body;

if (!hookId) {
return NextResponse.json(
{ error: 'hookId is required' },
{ status: 400 }
);
return NextResponse.json({ error: 'hookId is required' }, { status: 400 });
}

log.info('Detecting missed deliveries', { hookId, lookbackHours });
Expand Down Expand Up @@ -284,9 +271,6 @@ export async function PUT(req: NextRequest) {
return res;
} catch (error: any) {
log.error('Unexpected error detecting missed deliveries', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
});
83 changes: 83 additions & 0 deletions apps/backend/src/lib/github/github-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* GitHub App Webhook Authentication Middleware
*
* Wraps route handlers with HMAC-SHA256 signature verification for all
* /api/admin/webhooks/ routes. Secret is read from GITHUB_WEBHOOK_SECRET env var.
*
* On failure: returns 401 with WWW-Authenticate: HMAC, logs source IP.
* On success: calls the handler.
*/

import { NextRequest, NextResponse } from 'next/server';
import { createLogger } from '@/lib/api/logger';
import { verifyGitHubWebhookSignature } from './webhook-verification';

const log = createLogger({ service: 'github-webhook-auth' });

/**
* Timing-safe HMAC-SHA256 verification — thin wrapper over
* verifyGitHubWebhookSignature for consumers that prefer the shorter name.
*/
export function verifyGitHubSignature(
payload: string,
signature: string | null,
secret: string
): boolean {
return verifyGitHubWebhookSignature(payload, signature, secret);
}

type RouteHandler = (req: NextRequest, ctx?: any) => Promise<NextResponse>;

/**
* Middleware that verifies the X-Hub-Signature-256 header before calling the
* wrapped handler.
*
* - Reads the webhook secret from GITHUB_WEBHOOK_SECRET env var.
* - Returns 401 + WWW-Authenticate: HMAC on missing or invalid signature.
* - Logs signature failures with the source IP (x-forwarded-for or remote addr).
*/
export function withGitHubWebhookAuth(handler: RouteHandler): RouteHandler {
return async (req: NextRequest, ctx?: any): Promise<NextResponse> => {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) {
log.warn('GITHUB_WEBHOOK_SECRET is not configured; rejecting request');
return _unauthorized();
}

const signature = req.headers.get('x-hub-signature-256');
const sourceIp =
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';

let payload: string;
try {
payload = await req.text();
} catch {
return _unauthorized();
}

if (!verifyGitHubSignature(payload, signature, secret)) {
log.warn('GitHub webhook signature verification failed', { sourceIp });
return _unauthorized();
}

// Re-construct a request with the already-consumed body so the handler
// can call req.json() if it needs to.
const cloned = new Request(req.url, {
method: req.method,
headers: req.headers,
body: payload,
});

return handler(new NextRequest(cloned), ctx);
};
}

function _unauthorized(): NextResponse {
return NextResponse.json(
{ error: 'Unauthorized: invalid or missing webhook signature' },
{
status: 401,
headers: { 'WWW-Authenticate': 'HMAC' },
}
);
}
Loading
Loading