Skip to content

feat(backend): add webhook trigger - #30

Open
LukasHirt wants to merge 4 commits into
mainfrom
feat/webhook-trigger
Open

feat(backend): add webhook trigger#30
LukasHirt wants to merge 4 commits into
mainfrom
feat/webhook-trigger

Conversation

@LukasHirt

Copy link
Copy Markdown
Collaborator

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.

  • Body → vars: the request body is exposed as vars["webhook.body"] (raw string), plus flattened vars["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.
  • Resource path: stays empty unless the caller passes ?path=/foo/bar.txt explicitly.
  • Token lifecycle: generated server-side on first save of a webhook trigger, preserved across ordinary edits/enable-disable toggles (only an explicit "rotate" replaces it), and dropped if the trigger type changes away from webhook.
  • Auth reuse: running a webhook-triggered workflow reuses the exact "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/v1beta1 route group and its Validator.Middleware bearer-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:

  1. Constant-time comparison: the caller-supplied token is checked with crypto/subtle.ConstantTimeCompare, never ==.
  2. No oracle: an unknown workflow id, a non-webhook trigger, a not-yet-generated token, and a wrong token all return the identical 401 unauthenticated — a prober can't distinguish "wrong token" from "wrong/invalid workflow id".
  3. Rate limiting: a new pkg/ratelimit in-memory fixed-window limiter (30 req/token/minute by default) caps abuse of a single token, checked before any DB/oCIS API work.
  4. Secret hygiene: the token is stored encrypted at rest via the same secretbox mechanism as Automation.AppPassword (localdb.TriggerIndexEntry.WebhookToken), and is never included in the normal workflow GET/List/Patch responses — only readable via new, ordinary-bearer-auth-gated GET/POST /me/workflows/{id}/webhook-token(/rotate) endpoints (the NDV's "reveal"/"rotate" actions).
  5. No enabled-state leak: a disabled workflow still responds 202 for a valid token (just doesn't run), so a valid-token caller can't distinguish "ran" from "currently disabled".

Frontend

  • New trigger-webhook entry in nodeTypes.ts under TRIGGER_CATEGORY.
  • NDV: selecting the Webhook trigger type shows a "save first" hint until the workflow has an id (same constraint the manual run/executions actions already have), then shows a masked URL with Reveal/Copy/Rotate actions — mirroring the connect/disconnect interaction style of the existing automation affordance in WorkflowList.vue.
  • useWorkflowsApi gains getWebhookToken/rotateWebhookToken, combining the backend's returned path with the frontend's own non-/api/v1beta1 base URL (the webhook route lives outside it).

Other

docker-compose.yml's Traefik rule for workflows-backend is 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 — new RunWithVars seeds extra vars before execution (webhook body used in a template), and behaves like Run when extraVars is 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, GetTriggerIndexEntry not-found case, non-webhook entries with an empty webhook_token don't break decryption (migration-safety regression test), NewWebhookToken uniqueness.
  • pkg/server/http/server_test.go (new) — /hooks/... reaches HooksHandler with no Authorization header and rejects a wrong token there (proving it bypasses Validator.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-webhook registered under TRIGGER_CATEGORY, resolvable by id and by existing-node lookup.
  • tests/unit/useWorkflowsApi.spec.tsgetWebhookToken/rotateWebhookToken hit 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/RotateWebhookToken HTTP handlers aren't unit-tested at the HTTP layer, since WorkflowsHandler.store is a concrete *webdavstore.Store (no interface seam) rather than mockable — consistent with every other existing WorkflowsHandler method, 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.

@LukasHirt
LukasHirt requested a review from a team as a code owner July 24, 2026 16:33
@LukasHirt LukasHirt self-assigned this Jul 24, 2026
@LukasHirt
LukasHirt force-pushed the feat/webhook-trigger branch 2 times, most recently from d9c6196 to 721995f Compare July 27, 2026 14:18

@dj4oC dj4oC left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in encryptWebhookToken, why webhook entries survive disable). Good adherence to house style.
  • Interface segregation in hooks.go (HooksTriggerStore, HooksAutomationStore, HooksWorkflowStore, HooksExecutor) mirrors the existing Executor/TriggerIndexer pattern in workflows.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 after

ratelimit.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

@LukasHirt

Copy link
Copy Markdown
Collaborator Author

Addressed the two higher-severity findings:

  • chore(deps): Bump actions/setup-node from 6.3.0 to 7.0.0 #1 (rate limiter cardinality): moved the Allow() check to after the trigger-index lookup and keyed it by workflowID instead of the raw URL token. workflowID's cardinality is bounded by how many webhook-triggered workflows actually exist, so a flood of distinct garbage tokens (or garbage workflow ids) can no longer inflate the limiter's map — each unknown id 401s on the lookup before Allow is ever called. Repeated wrong-token guesses against one real workflow are still capped, which is the actual brute-force scenario this exists to stop. Added tests for both the fixed behavior and the boundary case (flooding unknown ids doesn't reach the limiter at all).
  • chore(deps): Bump pnpm/action-setup from 5.0.0 to 6.0.9 #3 (silent truncation): now reads one byte past the cap to detect overflow and returns 413 instead of silently feeding a truncated body into the executor. Added a test for the 413 case and the exact-at-the-cap boundary.

Pushing back on treating #2 and #4 as blocking:

  • chore(deps): Bump actions/checkout from 6.0.2 to 7.0.0 #2 (read-then-write race on first-save token generation): you already scoped this as "narrow window" — and it's narrower still than described, since the frontend save button is :disabled="saving" for the duration of the request, so a double-submit requires two genuinely separate save actions racing at the network level, not a double-click. A real fix means either wrapping syncTriggerIndex's webhook case in a mutex (cheap, but only helps if there's a single process — worth doing anyway) or an atomic "preserve existing token" SQL upsert (distinct from RotateWebhookToken's deliberate overwrite, so it'd need its own query). Given the actual likelihood here, I'd rather not bolt on new locking/SQL surface speculatively — happy to file it as a follow-up if you still want it addressed, but I don't think it should hold up this PR.
  • chore(deps-dev): Bump the minor-and-patch group in /frontend with 5 updates #4 (RotateWebhookToken doesn't re-check entry type): you already flagged this as "likely unreachable today given syncTriggerIndex is the only other writer" — I agree, and don't see a path to make it reachable without also changing syncTriggerIndex itself. Treating this as an awareness note rather than something to fix now.

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.
@LukasHirt
LukasHirt force-pushed the feat/webhook-trigger branch from 3496423 to 3c4d50f Compare July 29, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants