validate git repo urls and refs, require a secret for webhook triggers, tidy internal endpoint comments

This commit is contained in:
jarek committed 2026-07-31 10:10:56 +02:00
1 parent c18ee21a66
commit 0a9d4ad10f
10 files changed
+186 -91

No files matched your search

+5
View File
@@ -68,6 +68,11 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'A stack with this name already exists on this environment' }, { status: 409 });
}
// A secret is mandatory when the webhook is enabled.
if (data.webhookEnabled && !data.webhookSecret?.trim()) {
return json({ error: 'A webhook secret is required when the webhook is enabled' }, { status: 400 });
}
// Either repositoryId or new repo details (url, branch) must be provided
let repositoryId = data.repositoryId;
+12 -2
View File
@@ -63,6 +63,14 @@ export const PUT: RequestHandler = async (event) => {
data.stackName = trimmedStackName;
}
// A secret is mandatory when the webhook is enabled.
// Evaluate the effective post-update state (PUT is partial).
const effWebhookEnabled = data.webhookEnabled !== undefined ? data.webhookEnabled : existing.webhookEnabled;
const effWebhookSecret = data.webhookSecret !== undefined ? data.webhookSecret : existing.webhookSecret;
if (effWebhookEnabled && !effWebhookSecret?.trim()) {
return json({ error: 'A webhook secret is required when the webhook is enabled' }, { status: 400 });
}
const oldStackName = existing.stackName;
const updated = await updateGitStack(id, {
stackName: data.stackName,
@@ -195,8 +203,10 @@ export const DELETE: RequestHandler = async (event) => {
// Delete the stack_sources record to free up the stack name
await deleteStackSource(existing.stackName, existing.environmentId);
// Delete all env var overrides for this stack (all environments)
await deleteStackEnvVars(existing.stackName);
// Delete this stack's env var overrides ON THIS ENVIRONMENT ONLY. The same
// stack name can be deployed to multiple environments independently (per-env
// rows), so an unscoped delete would wipe another environment's vars/secrets.
await deleteStackEnvVars(existing.stackName, existing.environmentId);
// Delete from database
await deleteGitStack(id);
@@ -3,29 +3,7 @@ import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { auditGitStack } from '$lib/server/audit';
import crypto from 'node:crypto';
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
// Support both GitHub and GitLab webhook signatures
// GitHub: sha256=<hash>
// GitLab: just the token value in X-Gitlab-Token header
if (signature.startsWith('sha256=')) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
return signature === secret;
}
import { verifyWebhookSignature } from '$lib/server/webhook-signature';
function detectSource(request: Request): string {
if (request.headers.get('x-hub-signature-256')) return 'github';
@@ -52,20 +30,25 @@ export const POST: RequestHandler = async (event) => {
const source = detectSource(request);
// Verify webhook secret if set
if (gitStack.webhookSecret) {
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
// A secret is mandatory: reject if none is configured.
if (!gitStack.webhookSecret) {
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
method: 'POST', source, error: 'no_secret_configured'
});
return json({ error: 'Webhook secret is not configured for this stack' }, { status: 401 });
}
const signature = githubSignature || gitlabToken;
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
if (!verifySignature(payload, signature, gitStack.webhookSecret)) {
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
method: 'POST', source, error: 'invalid_signature'
});
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
const signature = githubSignature || gitlabToken;
if (!verifyWebhookSignature(payload, signature, gitStack.webhookSecret)) {
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
method: 'POST', source, error: 'invalid_signature'
});
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
// Deploy the git stack (syncs and deploys only if there are changes)
@@ -98,9 +81,17 @@ export const GET: RequestHandler = async (event) => {
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
}
// A secret is mandatory (see POST handler). Reject if none is configured.
if (!gitStack.webhookSecret) {
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
method: 'GET', source: 'get', error: 'no_secret_configured'
});
return json({ error: 'Webhook secret is not configured for this stack' }, { status: 401 });
}
// Verify secret via query parameter for GET requests
const secret = url.searchParams.get('secret');
if (gitStack.webhookSecret && secret !== gitStack.webhookSecret) {
if (secret !== gitStack.webhookSecret) {
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
method: 'GET', source: 'get', error: 'invalid_secret'
});
+27 -36
View File
@@ -3,29 +3,7 @@ import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { deployFromRepository } from '$lib/server/git';
import { auditGitRepository } from '$lib/server/audit';
import crypto from 'node:crypto';
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
// Support both GitHub and GitLab webhook signatures
// GitHub: sha256=<hash>
// GitLab: just the token value in X-Gitlab-Token header
if (signature.startsWith('sha256=')) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
return signature === secret;
}
import { verifyWebhookSignature } from '$lib/server/webhook-signature';
function detectSource(request: Request): string {
if (request.headers.get('x-hub-signature-256')) return 'github';
@@ -52,20 +30,25 @@ export const POST: RequestHandler = async (event) => {
const source = detectSource(request);
// Verify webhook secret if set
if (repository.webhookSecret) {
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
// A secret is mandatory: reject if none is configured.
if (!repository.webhookSecret) {
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 });
}
const signature = githubSignature || gitlabToken;
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
if (!verifySignature(payload, signature, repository.webhookSecret)) {
await auditGitRepository(event, 'webhook', id, repository.name, {
method: 'POST', source, error: 'invalid_signature'
});
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
const signature = githubSignature || gitlabToken;
if (!verifyWebhookSignature(payload, signature, repository.webhookSecret)) {
await auditGitRepository(event, 'webhook', id, repository.name, {
method: 'POST', source, error: 'invalid_signature'
});
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
// Optionally check which branch was pushed (for GitHub)
@@ -104,9 +87,17 @@ 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) {
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 });
}
// Verify secret via query parameter for GET requests
const secret = url.searchParams.get('secret');
if (repository.webhookSecret && secret !== repository.webhookSecret) {
if (secret !== repository.webhookSecret) {
await auditGitRepository(event, 'webhook', id, repository.name, {
method: 'GET', source: 'get', error: 'invalid_secret'
});
-1
View File
@@ -6,7 +6,6 @@ import type { RequestHandler } from './$types';
* GET /api/jobs/[id]
* Poll a job's status and accumulated lines.
* Returns all lines every time — client tracks its own cursor locally.
* No auth required: job IDs are UUIDs (unguessable), no sensitive data beyond what the initiating user triggered.
*/
export const GET: RequestHandler = async ({ params }) => {
const job = getJob(params.id);
+18 -9
View File
@@ -20,10 +20,7 @@
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
// Get sidebar state to adjust modal positioning
const sidebar = useSidebar();
// localStorage key for persisted split ratio
const STORAGE_KEY_SPLIT = 'dockhand-git-stack-modal-split';
@@ -149,7 +146,7 @@
let formError = $state('');
let formSaving = $state(false);
let showExistsWarning = $state(false);
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({});
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string; webhookSecret?: string }>({});
// Stack name validation: Docker Compose requires lowercase; must start with a
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
@@ -489,6 +486,11 @@
hasErrors = true;
}
if (formWebhookEnabled && !formWebhookSecret.trim()) {
errors.webhookSecret = 'A webhook secret is required when the webhook is enabled';
hasErrors = true;
}
if (hasErrors) return;
// Check if stack already exists (only for new stacks)
@@ -625,7 +627,7 @@
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (isOpen) focusFirstInput(); }}>
<Dialog.Content
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed' ? 'w-[calc(100vw-6rem)] ml-[1.5rem]' : 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
class="max-w-none w-[calc(100vw-4rem)] h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700"
showCloseButton={false}
>
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
@@ -965,7 +967,10 @@
<Webhook class="w-4 h-4 text-muted-foreground" />
<Label class="text-sm font-normal">Enable webhook</Label>
</div>
<TogglePill bind:checked={formWebhookEnabled} />
<TogglePill
bind:checked={formWebhookEnabled}
onchange={() => { if (formWebhookEnabled && !formWebhookSecret) formWebhookSecret = generateWebhookSecret(); }}
/>
</div>
<p class="text-xs text-muted-foreground">
Receive push events from your Git provider to trigger sync and redeploy.
@@ -1003,13 +1008,14 @@
</div>
{/if}
<div class="space-y-2">
<Label for="webhook-secret">Webhook secret (optional)</Label>
<Label for="webhook-secret">Webhook secret</Label>
<div class="flex gap-2">
<Input
id="webhook-secret"
bind:value={formWebhookSecret}
placeholder="Leave empty for no signature verification"
class="font-mono text-xs"
placeholder="Required - generate or paste a secret"
class="font-mono text-xs {errors.webhookSecret ? 'border-destructive focus-visible:ring-destructive' : ''}"
oninput={() => errors.webhookSecret = undefined}
/>
{#if gitStack && formWebhookSecret}
<Button
@@ -1045,6 +1051,9 @@
<Tooltip.Content>Generate secret</Tooltip.Content>
</Tooltip.Root>
</div>
{#if errors.webhookSecret}
<p class="text-xs text-destructive">{errors.webhookSecret}</p>
{/if}
</div>
{#if !gitStack}
<p class="text-xs text-muted-foreground">