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:
2026-05-07 02:34:40 +03:00
parent 793570f4a1
commit 831b5c1a43
14 changed files with 827 additions and 40 deletions
+30
View File
@@ -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 {