diff --git a/src/lib/server/webhook-secret-policy.ts b/src/lib/server/webhook-secret-policy.ts new file mode 100644 index 0000000..e60a8b2 --- /dev/null +++ b/src/lib/server/webhook-secret-policy.ts @@ -0,0 +1,28 @@ +/** + * Decision for whether a git-stack webhook trigger may proceed, given the repository's + * configured secret and the operator's opt-out env flag. Pure so it is unit-testable + * without a database or a live request. + * + * Default is secure: a secret is required. ALLOW_WEBHOOKS_WITHOUT_SECRET=true lets an + * operator accept a secret-less trigger on a network the webhook URL never leaves. The + * opt-out ONLY affects the "no secret configured" case - a repository that HAS a secret + * is always verified regardless of the flag. + */ +export type WebhookSecretDecision = + | { action: 'verify' } // a secret is configured; caller must verify the signature/query secret + | { action: 'deploy-unverified' } // no secret, but the opt-out is on; deploy without verification + | { action: 'reject-no-secret' }; // no secret and no opt-out; reject 401 + +export function decideWebhookSecretPolicy( + hasSecret: boolean, + allowSecretless: boolean +): WebhookSecretDecision { + if (hasSecret) return { action: 'verify' }; + if (allowSecretless) return { action: 'deploy-unverified' }; + return { action: 'reject-no-secret' }; +} + +/** Reads the opt-out env flag. Off unless explicitly set to the string "true". */ +export function allowSecretlessWebhook(): boolean { + return process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET === 'true'; +} diff --git a/src/routes/api/git/webhook/[id]/+server.ts b/src/routes/api/git/webhook/[id]/+server.ts index d98d831..5d74380 100644 --- a/src/routes/api/git/webhook/[id]/+server.ts +++ b/src/routes/api/git/webhook/[id]/+server.ts @@ -4,6 +4,7 @@ import { getGitRepository } from '$lib/server/db'; import { deployFromRepository } from '$lib/server/git'; import { auditGitRepository } from '$lib/server/audit'; import { verifyWebhookSignature } from '$lib/server/webhook-signature'; +import { decideWebhookSecretPolicy, allowSecretlessWebhook } from '$lib/server/webhook-secret-policy'; function detectSource(request: Request): string { if (request.headers.get('x-hub-signature-256')) return 'github'; @@ -14,7 +15,7 @@ function detectSource(request: Request): string { /** * @openapi * summary: Webhook trigger (GitHub/GitLab) that deploys from a git repository when its signature/token verifies - * description: Public endpoint authenticated by the repository's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification. + * description: Public endpoint authenticated by the repository's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification. A secret is required by default; set ALLOW_WEBHOOKS_WITHOUT_SECRET=true to accept a secret-less trigger on an isolated network. * path: id:integer! Git repository ID (from GET /api/git/repositories) * resp-200: {success:boolean, error:string} * resp-200-example: {"success":true} @@ -43,21 +44,32 @@ export const POST: RequestHandler = async (event) => { const source = detectSource(request); - // A secret is mandatory: reject if none is configured. - if (!repository.webhookSecret) { + const policy = decideWebhookSecretPolicy(!!repository.webhookSecret, allowSecretlessWebhook()); + if (policy.action === 'reject-no-secret') { await auditGitRepository(event, 'webhook', id, repository.name, { method: 'POST', source, error: 'no_secret_configured' }); return json({ error: 'Webhook secret is not configured for this repository' }, { status: 401 }); } + if (policy.action === 'deploy-unverified') { + // ALLOW_WEBHOOKS_WITHOUT_SECRET opt-in (isolated network): deploy without + // verification, but record the unverified trigger in the audit trail. + const result = await deployFromRepository(id); + await auditGitRepository(event, 'webhook', id, repository.name, { + method: 'POST', source, verification: 'skipped_no_secret', result: result.success ? 'deployed' : 'failed' + }); + return json(result); + } + // Reaching here means action === 'verify', i.e. a secret is configured. + const webhookSecret = repository.webhookSecret as string; const payload = await request.text(); const githubSignature = request.headers.get('x-hub-signature-256'); const gitlabToken = request.headers.get('x-gitlab-token'); const signature = githubSignature || gitlabToken; - if (!verifyWebhookSignature(payload, signature, repository.webhookSecret)) { + if (!verifyWebhookSignature(payload, signature, webhookSecret)) { await auditGitRepository(event, 'webhook', id, repository.name, { method: 'POST', source, error: 'invalid_signature' }); @@ -86,6 +98,7 @@ export const POST: RequestHandler = async (event) => { /** * @openapi * summary: GET webhook trigger for a git repository, with the secret passed as the `secret` query parameter + * description: A secret is required by default; set ALLOW_WEBHOOKS_WITHOUT_SECRET=true to accept a secret-less trigger on an isolated network. * path: id:integer! Git repository ID (from GET /api/git/repositories) * query: secret:string Webhook secret; required only if the repository has a webhook secret configured * resp-200: {success:boolean, error:string} @@ -113,13 +126,20 @@ export const GET: RequestHandler = async (event) => { return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 }); } - // A secret is mandatory (see POST handler). Reject if none is configured. - if (!repository.webhookSecret) { + const policy = decideWebhookSecretPolicy(!!repository.webhookSecret, allowSecretlessWebhook()); + if (policy.action === 'reject-no-secret') { await auditGitRepository(event, 'webhook', id, repository.name, { method: 'GET', source: 'get', error: 'no_secret_configured' }); return json({ error: 'Webhook secret is not configured for this repository' }, { status: 401 }); } + if (policy.action === 'deploy-unverified') { + const result = await deployFromRepository(id); + await auditGitRepository(event, 'webhook', id, repository.name, { + method: 'GET', source: 'get', verification: 'skipped_no_secret', result: result.success ? 'deployed' : 'failed' + }); + return json(result); + } // Verify secret via query parameter for GET requests const secret = url.searchParams.get('secret'); diff --git a/tests/unit/webhook-secret-policy.test.ts b/tests/unit/webhook-secret-policy.test.ts new file mode 100644 index 0000000..4df755c --- /dev/null +++ b/tests/unit/webhook-secret-policy.test.ts @@ -0,0 +1,49 @@ +// @ts-expect-error -- bun:test is a runtime built-in with no types installed +import { describe, test, expect, afterEach } from 'bun:test'; +import { decideWebhookSecretPolicy, allowSecretlessWebhook } from '../../src/lib/server/webhook-secret-policy'; + +// This proves the pure policy (all 4 combinations) + the env-flag parsing. The webhook +// handler is a thin wiring of decideWebhookSecretPolicy; the deploy-unverified branch +// (ALLOW_WEBHOOKS_WITHOUT_SECRET=true) needs the server booted with that env, which the +// shared CI instances do not set, so it is verified manually on a dedicated instance. + +describe('decideWebhookSecretPolicy', () => { + test('a configured secret is always verified (opt-out off)', () => { + expect(decideWebhookSecretPolicy(true, false)).toEqual({ action: 'verify' }); + }); + + test('a configured secret is verified even when the opt-out is on', () => { + // the opt-out must NOT weaken a webhook that has a secret + expect(decideWebhookSecretPolicy(true, true)).toEqual({ action: 'verify' }); + }); + + test('no secret and no opt-out is rejected (secure default)', () => { + expect(decideWebhookSecretPolicy(false, false)).toEqual({ action: 'reject-no-secret' }); + }); + + test('no secret with the opt-out on deploys unverified', () => { + expect(decideWebhookSecretPolicy(false, true)).toEqual({ action: 'deploy-unverified' }); + }); +}); + +describe('allowSecretlessWebhook (env flag)', () => { + const original = process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET; + afterEach(() => { + if (original === undefined) delete process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET; + else process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET = original; + }); + + test('off by default (unset)', () => { + delete process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET; + expect(allowSecretlessWebhook()).toBe(false); + }); + + test('only the exact string "true" enables it', () => { + process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET = 'true'; + expect(allowSecretlessWebhook()).toBe(true); + for (const v of ['1', 'TRUE', 'yes', 'on', '']) { + process.env.ALLOW_WEBHOOKS_WITHOUT_SECRET = v; + expect(allowSecretlessWebhook()).toBe(false); + } + }); +});