feat(webhook): HMAC-SHA256 signature verification on inbound webhooks
Adds an opt-in inbound HMAC scheme so a leaked URL alone is not enough
to forge deploy/sync requests — the caller must also know a separate
signing secret. Header format is X-Hub-Signature-256, matching the
Gitea/GitHub/GitLab convention so existing CI integrations work without
custom code.
Behaviour:
- per-project / per-site signing_secret is independent of the URL secret
- require_signature flag does a hard 401 on missing/invalid signatures
- even when require_signature is off, an *invalid* submitted signature
returns 401 — surfaces CI misconfiguration instead of silently passing
- comparison uses subtle/hmac.Equal (constant time)
Backend:
- store: webhook_signing_secret + webhook_require_signature columns on
projects + static_sites; scanProject helper, scan helpers updated; new
Set* helpers for both fields
- webhook/handler: verifyHMAC helper, body read once, integrated into
both project and site handlers
- api: per-entity signing-secret rotate / disable / require-toggle
endpoints under /api/{projects,sites}/{id}/webhook/...
Frontend:
- WebhookPanel gains optional signing handlers (no breaking change for
existing callers; signing UI hides when handlers aren't wired)
- one-shot reveal of the issued secret with copy + dismiss
- ToggleSwitch for require-signature, disabled until a secret is issued
- en/ru i18n strings
Tests:
- HMACRequiredAndValid (200 + deploy fires)
- HMACRequiredButMissing (401, no deploy)
- HMACPresentButWrong (401 even when require_signature=false)
- HMACOptionalUnsignedAccepted (200 when neither configured)
This commit is contained in:
@@ -328,6 +328,12 @@ export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
||||
export interface WebhookUrlResponse {
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
has_signing_secret?: boolean;
|
||||
webhook_require_signature?: boolean;
|
||||
}
|
||||
|
||||
export interface SigningSecretResponse {
|
||||
signing_secret: string;
|
||||
}
|
||||
|
||||
export function getProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
|
||||
@@ -338,6 +344,18 @@ export function regenerateProjectWebhook(projectId: string): Promise<WebhookUrlR
|
||||
return post<WebhookUrlResponse>(`/api/projects/${projectId}/webhook/regenerate`);
|
||||
}
|
||||
|
||||
export function regenerateProjectSigningSecret(projectId: string): Promise<SigningSecretResponse> {
|
||||
return post<SigningSecretResponse>(`/api/projects/${projectId}/webhook/signing-secret/regenerate`);
|
||||
}
|
||||
|
||||
export async function disableProjectSigningSecret(projectId: string): Promise<void> {
|
||||
await del<void>(`/api/projects/${projectId}/webhook/signing-secret`);
|
||||
}
|
||||
|
||||
export async function setProjectRequireSignature(projectId: string, require: boolean): Promise<void> {
|
||||
await put<void>(`/api/projects/${projectId}/webhook/require-signature`, { require_signature: require });
|
||||
}
|
||||
|
||||
export function getStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
|
||||
return get<WebhookUrlResponse>(`/api/sites/${siteId}/webhook`);
|
||||
}
|
||||
@@ -346,6 +364,18 @@ export function regenerateStaticSiteWebhook(siteId: string): Promise<WebhookUrlR
|
||||
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
|
||||
}
|
||||
|
||||
export function regenerateStaticSiteSigningSecret(siteId: string): Promise<SigningSecretResponse> {
|
||||
return post<SigningSecretResponse>(`/api/sites/${siteId}/webhook/signing-secret/regenerate`);
|
||||
}
|
||||
|
||||
export async function disableStaticSiteSigningSecret(siteId: string): Promise<void> {
|
||||
await del<void>(`/api/sites/${siteId}/webhook/signing-secret`);
|
||||
}
|
||||
|
||||
export async function setStaticSiteRequireSignature(siteId: string, require: boolean): Promise<void> {
|
||||
await put<void>(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require });
|
||||
}
|
||||
|
||||
// ── Outgoing-webhook signing & test ────────────────────────────────
|
||||
|
||||
export interface NotificationSecretResponse {
|
||||
|
||||
@@ -11,11 +11,18 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconCopy, IconRefresh, IconLoader } from '$lib/components/icons';
|
||||
import ToggleSwitch from './ToggleSwitch.svelte';
|
||||
import { IconCopy, IconRefresh, IconLoader, IconShield, IconX } from '$lib/components/icons';
|
||||
|
||||
interface WebhookUrlResponse {
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
has_signing_secret?: boolean;
|
||||
webhook_require_signature?: boolean;
|
||||
}
|
||||
|
||||
interface SigningSecretResponse {
|
||||
signing_secret: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -23,15 +30,35 @@
|
||||
description: string;
|
||||
fetchWebhook: () => Promise<WebhookUrlResponse>;
|
||||
regenerateWebhook: () => Promise<WebhookUrlResponse>;
|
||||
// Inbound HMAC signing — optional; if omitted, the signing UI hides.
|
||||
regenerateSigningSecret?: () => Promise<SigningSecretResponse>;
|
||||
disableSigning?: () => Promise<void>;
|
||||
setRequireSignature?: (require: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
let { title, description, fetchWebhook, regenerateWebhook }: Props = $props();
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
fetchWebhook,
|
||||
regenerateWebhook,
|
||||
regenerateSigningSecret,
|
||||
disableSigning,
|
||||
setRequireSignature
|
||||
}: Props = $props();
|
||||
|
||||
let relativeUrl = $state('');
|
||||
let loading = $state(true);
|
||||
let regenerating = $state(false);
|
||||
let confirmOpen = $state(false);
|
||||
|
||||
// Signing state.
|
||||
let hasSigningSecret = $state(false);
|
||||
let requireSignature = $state(false);
|
||||
// Newly issued signing secret — displayed once after rotate, hidden on next load.
|
||||
let issuedSigningSecret = $state('');
|
||||
let signingBusy = $state(false);
|
||||
let confirmDisableSigning = $state(false);
|
||||
|
||||
const absoluteUrl = $derived(
|
||||
relativeUrl && typeof window !== 'undefined' ? window.location.origin + relativeUrl : relativeUrl
|
||||
);
|
||||
@@ -41,6 +68,11 @@
|
||||
try {
|
||||
const res = await fetchWebhook();
|
||||
relativeUrl = res.webhook_url;
|
||||
hasSigningSecret = res.has_signing_secret ?? false;
|
||||
requireSignature = res.webhook_require_signature ?? false;
|
||||
// Hide any previously-displayed issued secret on reload — it
|
||||
// must only ever be shown once at issue time.
|
||||
issuedSigningSecret = '';
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.loadFailed'));
|
||||
} finally {
|
||||
@@ -54,6 +86,8 @@
|
||||
try {
|
||||
const res = await regenerateWebhook();
|
||||
relativeUrl = res.webhook_url;
|
||||
hasSigningSecret = res.has_signing_secret ?? hasSigningSecret;
|
||||
requireSignature = res.webhook_require_signature ?? requireSignature;
|
||||
toasts.success($t('webhookPanel.regenerated'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.regenerateFailed'));
|
||||
@@ -70,6 +104,59 @@
|
||||
);
|
||||
}
|
||||
|
||||
function copyIssuedSecret() {
|
||||
if (!issuedSigningSecret) return;
|
||||
navigator.clipboard.writeText(issuedSigningSecret).then(
|
||||
() => toasts.info($t('webhookPanel.signingCopied')),
|
||||
() => toasts.error($t('webhookPanel.copyFailed'))
|
||||
);
|
||||
}
|
||||
|
||||
async function handleIssueSigning() {
|
||||
if (!regenerateSigningSecret) return;
|
||||
signingBusy = true;
|
||||
try {
|
||||
const res = await regenerateSigningSecret();
|
||||
issuedSigningSecret = res.signing_secret;
|
||||
hasSigningSecret = true;
|
||||
toasts.success($t('webhookPanel.signingIssued'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.signingIssueFailed'));
|
||||
} finally {
|
||||
signingBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisableSigning() {
|
||||
if (!disableSigning) return;
|
||||
confirmDisableSigning = false;
|
||||
signingBusy = true;
|
||||
try {
|
||||
await disableSigning();
|
||||
hasSigningSecret = false;
|
||||
requireSignature = false;
|
||||
issuedSigningSecret = '';
|
||||
toasts.success($t('webhookPanel.signingDisabled'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.signingDisableFailed'));
|
||||
} finally {
|
||||
signingBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRequire(next: boolean) {
|
||||
if (!setRequireSignature) return;
|
||||
// Optimistic UI; revert on error.
|
||||
const previous = requireSignature;
|
||||
requireSignature = next;
|
||||
try {
|
||||
await setRequireSignature(next);
|
||||
} catch (err) {
|
||||
requireSignature = previous;
|
||||
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.signingRequireFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
});
|
||||
@@ -133,4 +220,111 @@
|
||||
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('webhookPanel.regenerateWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- HMAC signing section (rendered only if the parent wired the handlers). -->
|
||||
{#if regenerateSigningSecret && setRequireSignature && disableSigning}
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<IconShield size={18} />
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('webhookPanel.signingTitle')}</h3>
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{$t('webhookPanel.signingDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if issuedSigningSecret}
|
||||
<!-- One-shot display of the freshly issued secret. -->
|
||||
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
||||
<p class="text-xs font-medium text-amber-900 dark:text-amber-200">{$t('webhookPanel.signingShownOnce')}</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<code class="flex-1 break-all rounded bg-[var(--surface-card)] px-2 py-1.5 font-mono text-xs text-[var(--text-primary)]">
|
||||
{issuedSigningSecret}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onclick={copyIssuedSecret}
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconCopy size={12} />
|
||||
{$t('webhookPanel.copy')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (issuedSigningSecret = '')}
|
||||
title={$t('webhookPanel.signingDismiss')}
|
||||
aria-label={$t('webhookPanel.signingDismiss')}
|
||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)]"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-amber-900 dark:text-amber-200">
|
||||
{$t('webhookPanel.signingHint', { header: 'X-Hub-Signature-256' })}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-[var(--text-secondary)]">
|
||||
{#if hasSigningSecret}
|
||||
{$t('webhookPanel.signingActive')}
|
||||
{:else}
|
||||
{$t('webhookPanel.signingInactive')}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleIssueSigning}
|
||||
disabled={signingBusy || loading}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if signingBusy}<IconLoader size={14} />{/if}
|
||||
{hasSigningSecret ? $t('webhookPanel.signingRotate') : $t('webhookPanel.signingIssue')}
|
||||
</button>
|
||||
{#if hasSigningSecret}
|
||||
{#if confirmDisableSigning}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDisableSigning}
|
||||
disabled={signingBusy}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-danger)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{$t('webhookPanel.signingDisableConfirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDisableSigning = false)}
|
||||
class="rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]"
|
||||
>
|
||||
{$t('webhookPanel.confirmNo')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDisableSigning = true)}
|
||||
disabled={signingBusy}
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{$t('webhookPanel.signingDisable')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<ToggleSwitch
|
||||
checked={requireSignature}
|
||||
disabled={!hasSigningSecret || signingBusy}
|
||||
onchange={handleToggleRequire}
|
||||
label={$t('webhookPanel.requireSignature')}
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('webhookPanel.requireSignature')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('webhookPanel.requireSignatureHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1184,7 +1184,26 @@
|
||||
"regenerateWarning": "Regenerating invalidates the current URL. Update any CI pipeline or Git webhook that uses it.",
|
||||
"confirmRegenerate": "Replace the current URL?",
|
||||
"confirmYes": "Regenerate",
|
||||
"confirmNo": "Cancel"
|
||||
"confirmNo": "Cancel",
|
||||
"signingTitle": "Inbound HMAC signing",
|
||||
"signingDesc": "Verify webhook payloads with an HMAC-SHA256 signature so a leaked URL alone cannot be used to forge requests. Compatible with Gitea/GitHub webhook secrets.",
|
||||
"signingActive": "Signing secret configured.",
|
||||
"signingInactive": "No signing secret — inbound requests are not authenticated beyond the URL.",
|
||||
"signingIssue": "Issue signing secret",
|
||||
"signingRotate": "Rotate signing secret",
|
||||
"signingDisable": "Disable signing",
|
||||
"signingDisableConfirm": "Disable signing",
|
||||
"signingIssued": "New signing secret issued — copy it before leaving this page",
|
||||
"signingIssueFailed": "Failed to issue signing secret",
|
||||
"signingDisabled": "Signing disabled",
|
||||
"signingDisableFailed": "Failed to disable signing",
|
||||
"signingShownOnce": "Copy this secret now — it will not be shown again.",
|
||||
"signingDismiss": "Dismiss",
|
||||
"signingHint": "Set this as the webhook secret in Gitea/GitHub/GitLab. Tinyforge expects {header} on every request.",
|
||||
"signingCopied": "Signing secret copied to clipboard",
|
||||
"requireSignature": "Require signature",
|
||||
"requireSignatureHelp": "Reject any request that lacks a valid signature. Issue a signing secret first.",
|
||||
"signingRequireFailed": "Failed to update signature requirement"
|
||||
},
|
||||
"outgoingWebhook": {
|
||||
"signingOn": "Signed",
|
||||
|
||||
@@ -1184,7 +1184,26 @@
|
||||
"regenerateWarning": "Перегенерация инвалидирует текущий URL. Обновите CI-пайплайны и Git-вебхуки, использующие его.",
|
||||
"confirmRegenerate": "Заменить текущий URL?",
|
||||
"confirmYes": "Перегенерировать",
|
||||
"confirmNo": "Отмена"
|
||||
"confirmNo": "Отмена",
|
||||
"signingTitle": "Подпись входящих вебхуков (HMAC)",
|
||||
"signingDesc": "Проверка подписи HMAC-SHA256 — утечка только URL не позволит подделать запрос. Совместимо с секретами вебхуков Gitea/GitHub.",
|
||||
"signingActive": "Секрет подписи настроен.",
|
||||
"signingInactive": "Секрет подписи не задан — входящие запросы не проверяются помимо URL.",
|
||||
"signingIssue": "Сгенерировать секрет",
|
||||
"signingRotate": "Перевыпустить секрет",
|
||||
"signingDisable": "Отключить подпись",
|
||||
"signingDisableConfirm": "Отключить",
|
||||
"signingIssued": "Новый секрет подписи выпущен — скопируйте его сейчас",
|
||||
"signingIssueFailed": "Не удалось сгенерировать секрет подписи",
|
||||
"signingDisabled": "Подпись отключена",
|
||||
"signingDisableFailed": "Не удалось отключить подпись",
|
||||
"signingShownOnce": "Скопируйте секрет сейчас — он больше не будет показан.",
|
||||
"signingDismiss": "Скрыть",
|
||||
"signingHint": "Используйте это значение как webhook-секрет в Gitea/GitHub/GitLab. Tinyforge ожидает заголовок {header}.",
|
||||
"signingCopied": "Секрет подписи скопирован в буфер обмена",
|
||||
"requireSignature": "Требовать подпись",
|
||||
"requireSignatureHelp": "Отклонять запросы без действительной подписи. Сначала сгенерируйте секрет.",
|
||||
"signingRequireFailed": "Не удалось обновить требование подписи"
|
||||
},
|
||||
"outgoingWebhook": {
|
||||
"signingOn": "Подпись включена",
|
||||
|
||||
@@ -806,6 +806,9 @@
|
||||
description={$t('projectDetail.webhookDesc')}
|
||||
fetchWebhook={() => api.getProjectWebhook(projectId)}
|
||||
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
||||
regenerateSigningSecret={() => api.regenerateProjectSigningSecret(projectId)}
|
||||
disableSigning={() => api.disableProjectSigningSecret(projectId)}
|
||||
setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)}
|
||||
/>
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
||||
|
||||
@@ -312,6 +312,9 @@
|
||||
description={$t('sites.webhookDesc')}
|
||||
fetchWebhook={() => api.getStaticSiteWebhook(siteId!)}
|
||||
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
||||
regenerateSigningSecret={() => api.regenerateStaticSiteSigningSecret(siteId!)}
|
||||
disableSigning={() => api.disableStaticSiteSigningSecret(siteId!)}
|
||||
setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)}
|
||||
/>
|
||||
|
||||
<!-- Outgoing notification URL (per-site override; falls through to global). -->
|
||||
|
||||
Reference in New Issue
Block a user