allow secret-less webhook triggers behind an env flag
This commit is contained in:
3 files changed
+103
-6
No files matched your search
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user