From e9d7504fcd48de8b6841f452eb176101bd00b7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Mon, 31 Aug 2026 07:56:03 +0200 Subject: [PATCH] Put every trigger endpoint behind an authentication subrequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit n8n's webhook, form and mcp endpoints carry no authentication of their own, and for an automation platform those endpoints are the platform: whatever a workflow reads or changes answers an unauthenticated request. The editor's login protects the editor and nothing else. N8N_WEBHOOK_AUTH_URL is required rather than optional — there is no way to deploy this without naming an authenticator. Co-Authored-By: Claude Opus 5 --- README.md | 22 ++++++++++ k8s/ingress/ingress-webhooks.yaml | 73 +++++++++++++++++++++++++++++++ k8s/kustomization.yaml | 1 + 3 files changed, 96 insertions(+) create mode 100644 k8s/ingress/ingress-webhooks.yaml diff --git a/README.md b/README.md index a2d3eb2..9b1136f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ The namespace must exist first, and so must the AppRole Secret the store reads. | `N8N_STORAGE`, `POSTGRES_STORAGE` | claim sizes, e.g. `2Gi`, `5Gi` | | `TIMEZONE` | e.g. `Europe/Paris`; drives cron triggers | | `INGRESS_CLASS`, `INGRESS_BODY_SIZE` | e.g. `nginx`, `"50m"` | +| `N8N_WEBHOOK_AUTH_URL` | an endpoint that answers 2xx for an authorised caller and 401/403 otherwise. **Required** — n8n's trigger endpoints carry no authentication of their own, so without this anyone who can reach the host can run any workflow. An OIDC provider's `userinfo` works as-is | | `TLS_ISSUER_NAME`, `TLS_ISSUER_KIND` | a cert-manager issuer, e.g. `ClusterIssuer` | | `VAULT_SERVER`, `VAULT_MOUNT`, `VAULT_SECRET_PATH` | where the two secrets live | | `VAULT_STORE_NAME`, `VAULT_APPROLE_SECRET` | the `ClusterSecretStore` this creates, and the AppRole Secret in `external-secrets` it authenticates with | @@ -93,3 +94,24 @@ before it holds anything that matters. **Backups.** The Postgres claim survives a delete of the workload; it does not survive the cluster. + +## Webhooks are not open + +n8n serves a workflow's trigger endpoints — `/webhook`, `/form`, `/mcp` and +their test and waiting variants — on the same host as the editor, and **they +carry no authentication of their own**. For an automation platform those +endpoints *are* the platform: whatever a workflow reads or changes is reachable +by an unauthenticated request unless something in front says otherwise. The +editor's login protects the editor, nothing else. + +So they are served by a second Ingress that puts an authentication subrequest in +front of every one of them, via `${N8N_WEBHOOK_AUTH_URL}`. There is deliberately +no way to deploy this without naming one. + +Callers **inside** the cluster reach the Service directly and never traverse the +Ingress, so a facade or a gateway in front of a workflow keeps working while the +outside door is shut. + +If you rename an endpoint with `N8N_ENDPOINT_WEBHOOK` and friends, rename it in +`k8s/ingress/ingress-webhooks.yaml` too. A prefix left out of that list is the +hole all over again. diff --git a/k8s/ingress/ingress-webhooks.yaml b/k8s/ingress/ingress-webhooks.yaml new file mode 100644 index 0000000..75946e4 --- /dev/null +++ b/k8s/ingress/ingress-webhooks.yaml @@ -0,0 +1,73 @@ +# Webhook endpoints, authenticated. +# +# n8n serves a workflow's trigger endpoints on the same host as the editor, and +# they carry **no authentication of their own** — a workflow behind one answers +# anybody who can reach the host. That is not a detail: for an automation +# platform, those endpoints are the platform. Anything a workflow can read or +# change is reachable by an unauthenticated request unless something in front +# says otherwise. +# +# So this Ingress puts every trigger path behind an authentication subrequest. +# `${N8N_WEBHOOK_AUTH_URL}` must answer 2xx for an authorised request and +# 401/403 otherwise; an OIDC provider's userinfo endpoint has exactly that +# behaviour and needs nothing built. nginx forwards the caller's own headers to +# it, so a bearer on the original request is what gets checked. +# +# The editor keeps its own path (`/`) on the main Ingress and its own session +# login. Callers inside the cluster reach the Service directly and never pass +# through here — which is how an A2A facade in front of a workflow keeps working +# while the outside door is closed. +# +# Every trigger prefix n8n can serve is listed. One left out is the whole hole +# again, and the list is configurable per deployment +# (N8N_ENDPOINT_WEBHOOK and friends) — if you rename one, rename it here too. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: n8n-webhooks + namespace: ${NAMESPACE} + annotations: + nginx.ingress.kubernetes.io/auth-url: ${N8N_WEBHOOK_AUTH_URL} + nginx.ingress.kubernetes.io/proxy-body-size: ${INGRESS_BODY_SIZE} + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" +spec: + ingressClassName: ${INGRESS_CLASS} + tls: + - hosts: + - ${N8N_HOSTNAME} + secretName: n8n-tls + rules: + - host: ${N8N_HOSTNAME} + http: + paths: + - path: /webhook + pathType: Prefix + backend: &n8n + service: + name: n8n + port: + number: 80 + - path: /webhook-test + pathType: Prefix + backend: *n8n + - path: /webhook-waiting + pathType: Prefix + backend: *n8n + - path: /form + pathType: Prefix + backend: *n8n + - path: /form-test + pathType: Prefix + backend: *n8n + - path: /form-waiting + pathType: Prefix + backend: *n8n + - path: /mcp + pathType: Prefix + backend: *n8n + - path: /mcp-test + pathType: Prefix + backend: *n8n diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index ac9fb54..4443107 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -12,3 +12,4 @@ resources: - n8n/service.yaml - ingress/certificate.yaml - ingress/ingress.yaml + - ingress/ingress-webhooks.yaml