feat(backend): add webhook trigger - #30
Conversation
d9c6196 to
721995f
Compare
dj4oC
left a comment
There was a problem hiding this comment.
Review: feat(backend): add webhook trigger
Stats: +1746/-36 across 24 files (backend Go + Vue frontend)
Overview
Adds a fourth trigger type (webhook) alongside manual/schedule/event: an external caller can POST /hooks/{workflowId}/{token} to run a workflow without an oCIS session. The route is deliberately mounted outside the bearer-auth-gated /api/v1beta1 group, using the per-workflow token itself as the credential. Compensating controls: constant-time token comparison, identical 401 responses across all failure modes ("no oracle"), an in-memory rate limiter, encryption-at-rest for the stored token, and token lifecycle rules (generate-once, preserve-across-edits, explicit rotate). Request body is flattened into vars["webhook.body.*"] for downstream node templates. Frontend adds the trigger type plus a reveal/copy/rotate UI. Test coverage is extensive and well-targeted at the security properties the PR calls out.
Code quality & style
- Consistent with the codebase's existing convention of heavy "why, not what" comments explaining non-obvious invariants (e.g., why
""is special-cased inencryptWebhookToken, why webhook entries survive disable). Good adherence to house style. - Interface segregation in
hooks.go(HooksTriggerStore,HooksAutomationStore,HooksWorkflowStore,HooksExecutor) mirrors the existingExecutor/TriggerIndexerpattern inworkflows.go— consistent and testable. - Reuse of the exact
"Basic "+base64(...)construction from the cron/SSE paths, rather than inventing a new headless-auth mechanism, is the right call.
Potential issues & risks
1. Rate limiter's key cardinality is attacker-controlled and unbounded — undermines its own purpose (Medium-High)
In pkg/service/hooks.go, Trigger calls h.limiter.Allow(token) using the raw URL-path token before any lookup validates it exists:
if h.limiter != nil && !h.limiter.Allow(token) { ... }
entry, err := h.triggers.GetTriggerIndexEntry(r.Context(), workflowID) // validated only afterratelimit.Limiter (pkg/ratelimit/ratelimit.go) stores one map entry per distinct key, and only sweeps entries once resetAt (a full webhookRateLimitWindow = 1 minute) has elapsed. An attacker doesn't need a guessed-valid token to grow this map — any distinct garbage string in the {token} path segment creates a new entry. Within a single 1-minute window nothing is evicted (nothing is stale yet), so a flood of unique bogus tokens can grow counters without bound for the whole window, before evictStaleLocked (which only fires once len >= 1000 anyway) can reclaim anything. This is a memory-exhaustion DoS vector on a route the PR itself frames as deliberately public/unauthenticated — exactly the traffic pattern the limiter exists to guard against.
Suggestion: rate-limit only after confirming the token corresponds to a real, valid webhook entry (key by workflowID, or by the entry's stored token rather than the caller-supplied one), or cap the limiter's total tracked-key count (LRU eviction / hard ceiling) independent of staleness.
2. Read-then-write race on first-save token generation (Low)
In syncTriggerIndex (workflows.go), the webhook case does GetTriggerIndexEntry → if empty, generate → Upsert, with no transaction/locking. Two concurrent first-saves of the same new workflow (e.g., double-submit) could each generate a different token and race on the upsert, silently discarding one — indistinguishable from an unrequested rotation. Narrow window, but worth a comment or a guard if double-submit is plausible in the NDV.
3. Silent body truncation beyond 1 MiB (Low/informational)
io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) truncates without signaling truncation. A body just over 1 MiB gets silently cut, likely breaking JSON parsing (falls back to raw-string-only vars) with no indication to the caller. Acceptable as a DoS guard, but consider a 413 when the read hits the cap.
4. RotateWebhookToken doesn't re-check the trigger-index entry's existing type (Low)
It trusts wf.Trigger.Type == "webhook" from the workflow document but blindly overwrites the local trigger-index row. If that row ever drifted (e.g., a stale event/schedule entry), rotate would clobber it. Likely unreachable today given syncTriggerIndex is the only other writer, but flagging for awareness.
Test coverage
Strong — the security properties are each independently tested: wrong/missing/unknown-workflow/non-webhook-trigger all → 401 with no run; rate limiting caps at N then 429 with independent per-token budgets; encryption-at-rest verified by reading the raw column; token preserved across updates/disable, replaced only on explicit rotate and dropped on type-change; server_test.go proves the route bypasses Validator.Middleware while /api/v1beta1 still requires it. Gap: no test exercises many distinct tokens against the rate limiter (which would surface finding #1) — worth adding once that's addressed.
Security summary
The documented threat model (no oracle, constant-time compare, encrypted-at-rest token, no enabled-state leak) is implemented faithfully and is genuinely solid. The rate limiter is the one place where the implementation doesn't match the stated intent — it's meant to be the compensating control for a route with no other auth, but as written it can become the attack surface itself.
🤖 Generated with Claude Code
721995f to
e8b5cc5
Compare
|
Addressed the two higher-severity findings:
Pushing back on treating #2 and #4 as blocking:
|
Add a webhook trigger alongside manual/schedule/event: an external
caller (a CI pipeline, a form submission, another SaaS's outgoing
webhook) can now kick off a workflow via
POST /hooks/{workflowId}/{token}, without inventing new oCIS event
types. The request body is exposed to the graph as
vars["webhook.body"] (raw string), plus flattened
vars["webhook.body.<key>"] when it parses as a JSON object; a
malformed or non-object body degrades gracefully instead of failing
the run. The resource path stays empty unless the caller passes
?path=.
The webhook token is generated server-side (crypto/rand, 32 bytes),
stored via the same secretbox encryption already used for automation
app-passwords (localdb.TriggerIndexEntry gains a WebhookToken
column), and never included in ordinary workflow GET/List/Patch
responses — it's only readable through new, bearer-token-guarded
GET/POST /me/workflows/{id}/webhook-token(/rotate) endpoints. Running
a webhook-triggered workflow reuses the exact same
"Basic "+base64(automation.Username+":"+automation.AppPassword) auth
construction the cron scheduler and SSE consumer manager already use
for running without a live user session — no new auth plumbing.
Because POST /hooks/{workflowId}/{token} is intentionally mounted
outside the /api/v1beta1 route group (and its Validator.Middleware
bearer-token gate), the token itself is the only credential: it's
checked with crypto/subtle.ConstantTimeCompare rather than ==, unknown
workflow ids and non-webhook trigger types are rejected with the same
401 as a wrong token (no oracle for probing valid ids), and a new
pkg/ratelimit fixed-window limiter caps requests per token as a
compensating control against flooding/guessing.
Tests cover: wrong/missing/mismatched token rejection, a valid token
running with flattened JSON vars, non-JSON/empty/array bodies firing
without flattening or crashing, a disabled workflow not running,
missing automation failing gracefully, rate limiting kicking in after
N requests, webhook token generation/preservation/rotation semantics
in the trigger index sync, and that the /hooks route reaches its
handler without a bearer token while /api/v1beta1 routes still
require one.
docker-compose.yml's Traefik rule for workflows-backend is widened to
also match /workflows/hooks, matching the new public route.
Signed-off-by: Lukas Hirt <info@hirt.cz>
Add a "Webhook Trigger" entry (trigger-webhook) under the Triggers
category in nodeTypes.ts, matching the new backend trigger type.
The NDV's trigger type select gains a Webhook option. Once a workflow
has been saved (same "need a saved workflow id first" constraint the
manual run/executions actions already have), selecting it fetches and
displays the generated webhook URL, masked by default with a Reveal
toggle, a one-click Copy-to-clipboard action, and a Rotate action that
replaces the token — mirroring the connect/disconnect interaction
style of the existing automation affordance in WorkflowList.vue.
Before saving, it shows a hint to save first instead.
useWorkflowsApi gains getWebhookToken/rotateWebhookToken, which call
the new GET/POST /me/workflows/{id}/webhook-token(/rotate) endpoints
and combine the returned path with the backend's own non-API base URL
(stripping the /api/v1beta1 suffix) to build the full webhook URL,
since the webhook route itself lives outside /api/v1beta1.
Signed-off-by: Lukas Hirt <info@hirt.cz>
…sized bodies with 413 The limiter keyed on the raw, caller-supplied URL token before any lookup validated it — a flood of distinct garbage tokens could grow its map without bound for a full window, on the exact public, token-gated route the limiter exists to protect. Move the check to after the trigger-index lookup and key by workflowID instead, whose cardinality is bounded by how many webhook-triggered workflows actually exist. Repeated wrong-token guesses against one real workflow are still capped, which is the actual brute-force scenario the limiter is meant to stop. Also stop silently truncating request bodies over the 1 MiB cap: LimitReader alone can't tell "exactly at the cap" from "more, cut off", so an oversized body was fed into the executor truncated with no signal to the caller. Read one byte past the cap to detect overflow and return 413 instead.
3496423 to
3c4d50f
Compare
Summary
Implements the "Webhook Trigger" proposal end to end: a trigger, alongside manual/schedule/event, that fires when an external HTTP request hits a per-workflow URL —
POST /hooks/{workflowId}/{token}— so something outside oCIS's own event bus (a CI pipeline, a form submission, another SaaS's outgoing webhook) can kick off a workflow without inventing new oCIS event types.vars["webhook.body"](raw string), plus flattenedvars["webhook.body.<key>"]for each top-level key when it parses as a JSON object. Non-JSON/malformed/array/empty bodies still fire the trigger with just the raw string — no crash, no flattening.?path=/foo/bar.txtexplicitly.webhook."Basic " + base64(automation.Username + ":" + automation.AppPassword)construction the cron scheduler and SSE consumer manager already build for running without a live user session — no new "run headlessly" auth path was invented.Security model (the part the original proposal flagged as the open risk)
POST /hooks/{workflowId}/{token}is deliberately mounted outside the/api/v1beta1route group and itsValidator.Middlewarebearer-token gate — an external caller has no oCIS session to present a bearer token from, so the token embedded in the URL is the credential. Compensating controls:crypto/subtle.ConstantTimeCompare, never==.401 unauthenticated— a prober can't distinguish "wrong token" from "wrong/invalid workflow id".pkg/ratelimitin-memory fixed-window limiter (30 req/token/minute by default) caps abuse of a single token, checked before any DB/oCIS API work.secretboxmechanism asAutomation.AppPassword(localdb.TriggerIndexEntry.WebhookToken), and is never included in the normal workflow GET/List/Patch responses — only readable via new, ordinary-bearer-auth-gatedGET/POST /me/workflows/{id}/webhook-token(/rotate)endpoints (the NDV's "reveal"/"rotate" actions).202for a valid token (just doesn't run), so a valid-token caller can't distinguish "ran" from "currently disabled".Frontend
trigger-webhookentry innodeTypes.tsunderTRIGGER_CATEGORY.WorkflowList.vue.useWorkflowsApigainsgetWebhookToken/rotateWebhookToken, combining the backend's returned path with the frontend's own non-/api/v1beta1base URL (the webhook route lives outside it).Other
docker-compose.yml's Traefik rule forworkflows-backendis widened to also match/workflows/hooks, so the new public route is actually reachable in the local dev stack.Test plan
Backend (
cd backend && go test ./..., all green):pkg/service/hooks_test.go— wrong/missing/mismatched token → 401 (no run); unknown workflow id → 401 (no oracle); non-webhook trigger entry → 401; valid token → 202 + run with flattened JSON vars + execution record stored;?path=honored, empty by default; non-JSON/empty/JSON-array bodies → 202, raw string only, no flattening, no crash; disabled workflow → 202, no run; owner with no automation connected → 503, no run; rate limiting → 202×N then 429, independent budgets per token.pkg/service/workflows_test.go— webhook token generated on first save, preserved across updates and across enable/disable toggles, index entry removed when trigger type changes away from webhook; pre-existing schedule-trigger disable-deletes-entry behavior unchanged.pkg/executor/executor_test.go— newRunWithVarsseeds extra vars before execution (webhook body used in a template), and behaves likeRunwhenextraVarsis nil.pkg/ratelimit/ratelimit_test.go— allows up to max then blocks, independent per-key budgets, resets after the window elapses.pkg/localdb/localdb_test.go— webhook token round-trips encrypted at rest (raw column isn't plaintext), rotation replaces rather than appends,GetTriggerIndexEntrynot-found case, non-webhook entries with an emptywebhook_tokendon't break decryption (migration-safety regression test),NewWebhookTokenuniqueness.pkg/server/http/server_test.go(new) —/hooks/...reachesHooksHandlerwith noAuthorizationheader and rejects a wrong token there (proving it bypassesValidator.Middleware), while/api/v1beta1/...still 401s without a bearer token.go vet ./...,gofmt -l .clean.Frontend (
npm run test:unit,npm run check:types,npm run lint, all green):tests/unit/nodeTypes.spec.ts(new) —trigger-webhookregistered underTRIGGER_CATEGORY, resolvable by id and by existing-node lookup.tests/unit/useWorkflowsApi.spec.ts—getWebhookToken/rotateWebhookTokenhit the right endpoints and build the full URL from the returned path.tests/unit/NodeDetailsPanel.spec.ts(new) — shows a "save first" hint when unsaved; fetches and masks the URL once saved; Reveal shows it; Copy copies without forcing reveal; Rotate replaces the displayed value.Not covered by automated tests (documented tradeoff): the new
WorkflowsHandler.WebhookToken/RotateWebhookTokenHTTP handlers aren't unit-tested at the HTTP layer, sinceWorkflowsHandler.storeis a concrete*webdavstore.Store(no interface seam) rather than mockable — consistent with every other existingWorkflowsHandlermethod, none of which have unit tests either (only e2e coverage against a real stack). The security-critical logic they depend on (syncTriggerIndex's token generation/preservation) is fully unit-tested directly.