Some checks failed
check / check (push) Failing after 2m31s
A receiver URL was a bare v4 UUID and nothing else: anyone who learned it could store events and, because inbound headers are forwarded to targets almost verbatim, choose what the downstream service received. Entrypoints gain an optional scheme/secret pair. GitHub's X-Hub-Signature-256 (HMAC-SHA256 hex over the raw body) and GitLab's X-Gitlab-Token (plain shared token) are supported; both compare with hmac.Equal. With nothing configured an entrypoint behaves exactly as before, which is also where every pre-existing row lands after AutoMigrate adds the columns. Verification runs after the capped body read and before the first write, so a rejected request leaves no event row, no delivery row and no delivery task. A configuration the receiver cannot apply — unknown scheme, or one half of the pair missing — is refused with a 500 rather than falling back to unverified. The secret is credential-bearing and is stored in the clear because HMAC needs the key itself. It is excluded from JSON, kept out of templates by a new handlers.EntrypointView projection, and absent from every log line including the rejection path. The UI sets and rotates it through one form that never renders the stored value. Under the GitLab scheme the signature header is the secret rather than a digest over the request, so an accepted request's headers are cloned and the configured scheme's credential header dropped before they are serialized onto the event. Stored headers are persisted verbatim in the per-webhook database and replayed onto every outbound delivery, so keeping the token would put it in every backup and hand every target operator the means to forge signed requests to the entrypoint it authenticates. Stripping sits once above the first write rather than at each egress, and is driven by the scheme's own description with stripping as the default: a scheme added later is covered unless it declares its header a digest, as GitHub's HMAC over the body does. An entrypoint holding one half of the pair now renders as misconfigured rather than as unverified, and the scheme selector follows the stored scheme so such a row no longer marks two options selected.
92 lines
2.7 KiB
Go
92 lines
2.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/signature"
|
|
)
|
|
|
|
// signatureUnavailable is what an entrypoint's scheme renders as when
|
|
// the stored value is not one this build supports. The stored string
|
|
// is never echoed as a fallback: it is operator-supplied and the row
|
|
// is already in a state the receiver refuses, so the UI says so
|
|
// rather than inventing a description for it.
|
|
const signatureUnavailable = "(unavailable)"
|
|
|
|
// signatureNotVerified is the label for an entrypoint that performs
|
|
// no inbound verification.
|
|
const signatureNotVerified = "not verified"
|
|
|
|
// signatureMisconfigured is the label for a row holding one half of
|
|
// the scheme/secret pair. The receiver answers every request to such
|
|
// an entrypoint 500, so calling it "not verified" would describe a
|
|
// receiver that is refusing everything as one that is accepting
|
|
// everything. The form cannot create the state; a hand-edited
|
|
// database or a downgrade past a scheme can.
|
|
const signatureMisconfigured = "misconfigured"
|
|
|
|
// EntrypointView is the display-safe projection of an entrypoint for
|
|
// the UI. It deliberately has no secret field, so no template —
|
|
// present or future — can render the shared secret, in the same way
|
|
// delivery.TargetView keeps a target's stored credential away from
|
|
// one.
|
|
type EntrypointView struct {
|
|
ID string
|
|
Path string
|
|
Description string
|
|
Active bool
|
|
|
|
// Configured reports whether inbound requests to this entrypoint
|
|
// are verified.
|
|
Configured bool
|
|
|
|
// Scheme is the stored scheme, carried so the form can preselect
|
|
// it. It names an algorithm, not a secret.
|
|
Scheme database.SignatureScheme
|
|
|
|
// SchemeLabel and SchemeHeader describe the configured scheme for
|
|
// display: the sender's name, and the header its signature
|
|
// arrives in.
|
|
SchemeLabel string
|
|
SchemeHeader string
|
|
}
|
|
|
|
// NewEntrypointViews projects entrypoints for rendering, dropping the
|
|
// shared secret on the way.
|
|
func NewEntrypointViews(
|
|
entrypoints []database.Entrypoint,
|
|
) []EntrypointView {
|
|
views := make([]EntrypointView, 0, len(entrypoints))
|
|
|
|
for i := range entrypoints {
|
|
e := &entrypoints[i]
|
|
|
|
view := EntrypointView{
|
|
ID: e.ID,
|
|
Path: e.Path,
|
|
Description: e.Description,
|
|
Active: e.Active,
|
|
Configured: e.SignatureConfigured(),
|
|
Scheme: e.SignatureScheme,
|
|
SchemeLabel: signatureNotVerified,
|
|
SchemeHeader: "",
|
|
}
|
|
|
|
switch {
|
|
case view.Configured:
|
|
view.SchemeLabel = signatureUnavailable
|
|
|
|
info, ok := signature.Info(e.SignatureScheme)
|
|
if ok {
|
|
view.SchemeLabel = info.Label
|
|
view.SchemeHeader = info.Header
|
|
}
|
|
case e.SignatureHalfConfigured():
|
|
view.SchemeLabel = signatureMisconfigured
|
|
}
|
|
|
|
views = append(views, view)
|
|
}
|
|
|
|
return views
|
|
}
|