Some checks failed
check / check (push) Superseded by a newer commit; never tested
The receiver had no inbound authentication of any kind: /webhook/{uuid}
was mounted behind a rate limiter alone, so the only thing protecting an
entrypoint was the secrecy of a v4 UUID in a URL path. Inbound headers are
forwarded almost verbatim to the target, so anyone who learned the URL
also chose the headers the downstream service received.
Adds an optional per-entrypoint secret with two schemes: github
(X-Hub-Signature-256, HMAC-SHA256 hex over the raw body) and gitlab
(X-Gitlab-Token, a plain shared token). Comparison is constant-time, the
HMAC is computed over the raw body before any parsing, and rejection
happens before persistence -- an unauthenticated request creates no event
row. An entrypoint with no secret behaves exactly as before, including
every row that predates this change.
The scheme's credential header is stripped from the header map before it
is marshalled into Event.Headers, so the GitLab token reaches neither the
event store nor any delivery target. SchemeInfo.HeaderIsDigest defaults to
false meaning strip, so a scheme added later is protected unless its
header is positively declared a digest.
237 lines
14 KiB
HTML
237 lines
14 KiB
HTML
{{template "base" .}}
|
|
|
|
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
|
|
|
|
{{define "content"}}
|
|
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
|
|
<div class="mb-6">
|
|
<a href="/sources" class="text-sm text-primary-600 hover:text-primary-700">← Back to webhooks</a>
|
|
<div class="flex justify-between items-center mt-2">
|
|
<div>
|
|
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
|
|
{{if .Webhook.Description}}
|
|
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p>
|
|
{{end}}
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-secondary">Event Log</a>
|
|
<a href="/source/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
|
|
<form method="POST" action="/source/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
|
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
<button type="submit" class="btn-danger">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<!-- Entrypoints -->
|
|
<div class="card">
|
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 class="text-lg font-medium text-gray-900">Entrypoints</h2>
|
|
<button @click="showAddEntrypoint = !showAddEntrypoint" class="btn-text text-sm">
|
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Add entrypoint form -->
|
|
<div x-show="showAddEntrypoint" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
|
<form method="POST" action="/source/{{.Webhook.ID}}/entrypoints" class="flex gap-2">
|
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
<input type="text" name="description" placeholder="Description (optional)" class="input text-sm flex-1">
|
|
<button type="submit" class="btn-primary text-sm">Add</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="divide-y divide-gray-100">
|
|
{{range .Entrypoints}}
|
|
<div class="p-4" x-data="{ showSecret: false }">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
|
|
<div class="flex items-center gap-2">
|
|
{{if .Active}}
|
|
<span class="badge-success">Active</span>
|
|
{{else}}
|
|
<span class="badge-error">Inactive</span>
|
|
{{end}}
|
|
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/toggle" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-start gap-2 mt-1">
|
|
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
|
<!-- Hidden until app.js reveals it; without the
|
|
script the URL above stays selectable. -->
|
|
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-2">
|
|
<span class="text-xs text-gray-500">
|
|
Signature: {{.SchemeLabel}}{{if .SchemeHeader}} ({{.SchemeHeader}}){{end}}
|
|
</span>
|
|
<button type="button" @click="showSecret = !showSecret" class="text-xs text-gray-500 hover:text-primary-600">
|
|
{{if .Configured}}Rotate{{else}}Configure{{end}}
|
|
</button>
|
|
</div>
|
|
<!-- The stored secret is never sent to the browser: the
|
|
form takes a new one every time, so setting and
|
|
rotating are the same submission. -->
|
|
<div x-show="showSecret" x-cloak class="mt-2">
|
|
<form method="POST" action="/source/{{$.Webhook.ID}}/entrypoints/{{.ID}}/secret" class="flex gap-2">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<select name="signature_scheme" class="input text-sm w-28">
|
|
<!-- Selection follows the stored scheme, not
|
|
whether the pair is complete: a row with a
|
|
scheme and no secret would otherwise mark
|
|
both this option and its own selected. -->
|
|
<option value="" {{if not .Scheme}}selected{{end}}>None</option>
|
|
{{$current := .Scheme}}
|
|
{{range $.SignatureSchemes}}
|
|
<option value="{{.Scheme}}" {{if eq .Scheme $current}}selected{{end}}>{{.Label}}</option>
|
|
{{end}}
|
|
</select>
|
|
<input type="password" name="secret" autocomplete="new-password" placeholder="Shared secret" class="input text-sm flex-1">
|
|
<button type="submit" class="btn-primary text-sm">Save</button>
|
|
</form>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
Enter the same secret you configured at the sender. Selecting None removes verification.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Targets -->
|
|
<div class="card">
|
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 class="text-lg font-medium text-gray-900">Targets</h2>
|
|
<button @click="showAddTarget = !showAddTarget" class="btn-text text-sm">
|
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
|
</svg>
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Add target form -->
|
|
<div x-show="showAddTarget" x-cloak class="p-4 bg-gray-50 border-b border-gray-200">
|
|
<form method="POST" action="/source/{{.Webhook.ID}}/targets" x-data="{ targetType: 'http' }" class="space-y-3">
|
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
<div class="flex gap-2">
|
|
<input type="text" name="name" placeholder="Target name" required class="input text-sm flex-1">
|
|
<select name="type" x-model="targetType" class="input text-sm w-32">
|
|
<option value="http">HTTP</option>
|
|
<option value="slack">Slack</option>
|
|
<option value="database">Database</option>
|
|
<option value="log">Log</option>
|
|
</select>
|
|
</div>
|
|
<div x-show="targetType === 'http'">
|
|
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
|
|
</div>
|
|
<div x-show="targetType === 'http'">
|
|
<textarea name="headers" rows="3" placeholder="Authorization: Bearer ..." :disabled="targetType !== 'http'" class="input text-sm"></textarea>
|
|
<p class="text-xs text-gray-500 mt-1">Optional request headers, one <code>Name: value</code> per line, sent with every delivery.</p>
|
|
</div>
|
|
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Timeout (seconds, blank = default):</label>
|
|
<input type="number" name="timeout" min="0" max="300" :disabled="targetType !== 'http'" class="input text-sm w-24">
|
|
</div>
|
|
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
|
|
<label class="text-sm text-gray-700">Max retries (0 = fire-and-forget):</label>
|
|
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
|
|
</div>
|
|
<div x-show="targetType === 'slack'">
|
|
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
|
|
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
|
|
</div>
|
|
<div x-show="targetType === 'database'">
|
|
<input type="text" name="expiry" placeholder="never" :disabled="targetType !== 'database'" class="input text-sm">
|
|
<p class="text-xs text-gray-500 mt-1">Archive expiry: "never" (default) keeps rows forever, or a duration like "720h" prunes older rows.</p>
|
|
</div>
|
|
<button type="submit" class="btn-primary text-sm">Add Target</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="divide-y divide-gray-100">
|
|
{{range .Targets}}
|
|
<div class="p-4">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
|
|
<div class="flex items-center gap-2">
|
|
<span class="badge-info">{{.Type}}</span>
|
|
{{if .Active}}
|
|
<span class="badge-success">Active</span>
|
|
{{else}}
|
|
<span class="badge-error">Inactive</span>
|
|
{{end}}
|
|
<a href="/source/{{$.Webhook.ID}}/targets/{{.ID}}/edit" class="text-xs text-gray-500 hover:text-primary-600" title="Edit">Edit</a>
|
|
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/toggle" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="text-xs text-gray-500 hover:text-primary-600" title="{{if .Active}}Deactivate{{else}}Activate{{end}}">
|
|
{{if .Active}}Deactivate{{else}}Activate{{end}}
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="/source/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
<button type="submit" class="text-xs text-red-500 hover:text-red-700" title="Delete">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{{range .Config}}
|
|
<div class="text-xs text-gray-500 break-all mt-1">
|
|
<span class="font-medium text-gray-700">{{.Label}}:</span>
|
|
<span>{{.Value}}</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{else}}
|
|
<div class="p-4 text-sm text-gray-500">No targets configured.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Events -->
|
|
<div class="card mt-6">
|
|
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 class="text-lg font-medium text-gray-900">Recent Events</h2>
|
|
<a href="/source/{{.Webhook.ID}}/logs" class="btn-text text-sm">View All</a>
|
|
</div>
|
|
<div class="divide-y divide-gray-100">
|
|
{{range .Events}}
|
|
<div class="p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span class="badge-info">{{.Method}}</span>
|
|
<span class="text-sm text-gray-500">{{.ContentType}}</span>
|
|
</div>
|
|
<span class="text-xs text-gray-400">{{.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</span>
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<div class="p-8 text-center text-sm text-gray-500">No events received yet.</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="mt-4 text-sm text-gray-400">
|
|
<p>Retention: {{.Webhook.RetentionLabel}} · Created: {{.Webhook.CreatedAt.Format "2006-01-02 15:04:05 UTC"}}</p>
|
|
</div>
|
|
</div>
|
|
{{end}}
|