Guard webhooks with a per-hook secret, and allow rotating the app key

Trigger hooks are mounted unauthenticated because devices cannot present
a JWT. They now take a secret as a trailing path segment, so a device
needs one URL and no header support. The value never enters the
registered route, only a {secret} template, and is compared with
compare_digest; a mismatch is a bare 404 so the endpoint does not
confirm which hooks exist. Pointing the parameter at the encrypted store
keeps the literal out of flow.json. Hooks without a secret keep working
and now raise a validation issue saying so.

Rotating SECRET_KEY made the stored secrets unreadable for good, since
the Fernet key derives from it. scripts/rotate_secret_key.py re-encrypts
with the new key and refuses if the old one does not decrypt. Now that
recovery exists, an unreadable store fails loudly instead of coming back
empty and leaving flows short of credentials with no visible cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
Melvin Strobl
2026-08-15 21:19:32 +02:00
co-authored by Claude Opus 5
parent 11d491dae0
commit ca7a16c8bc
5 changed files with 226 additions and 17 deletions
+18
View File
@@ -236,6 +236,24 @@ class FlowController:
for entry in loaded.values()
if entry.status is NodeStatus.ERROR
]
# Webhooks are mounted without authentication, so a hook without a
# secret is open to anyone who guesses its URL. Flows written before
# the parameter existed keep running, but say so.
self.issues += [
ValidationIssue(
code="unauthenticated_hook",
message=(
f"Webhook '{entry.node.local_id}' has no secret — "
"anyone who knows its URL can trigger it."
),
flow=entry.flow,
node=entry.id,
)
for entry in loaded.values()
if isinstance(entry.node, HttpNode)
and entry.node.mode == HttpNode.Mode.TRIGGER
and not entry.node.secret
]
await self._activate()
+35 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import hmac
import logging
import threading
import time
@@ -10,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal
import httpx
import numpy as np
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from app.flow.messages import MessageSpec, qualify
@@ -91,8 +92,8 @@ class Node:
self.output_ports = self._normalize_ports(provides)
self.flow = ""
self.name = name or getattr(f, "__name__", "node")
self.id = self.name
self.name: str = name or getattr(f, "__name__", "node")
self.id: str = self.name
self._index_ports()
@staticmethod
@@ -106,8 +107,12 @@ class Node:
def _index_ports(self) -> None:
"""Index bound ports by message name; unbound ports have no wiring."""
self.requires = {s.name: s for s in self.input_ports if s.name}
self.provides = {s.name: s for s in self.output_ports if s.name}
self.requires: dict[str, MessageSpec] = {
s.name: s for s in self.input_ports if s.name
}
self.provides: dict[str, MessageSpec] = {
s.name: s for s in self.output_ports if s.name
}
def assign_flow(self, flow: str, node_id: str) -> None:
"""Place this node in a flow, qualifying its identity and messages.
@@ -364,6 +369,8 @@ class HttpNode(Node):
:type timeout: float
:param headers: Additional HTTP headers.
:type headers: dict[str, str] | None
:param secret: Shared secret callers append to the webhook URL (trigger mode).
:type secret: str
:raises ValueError: If both ``requires`` and ``provides`` are empty, or if
the configuration is invalid.
@@ -400,6 +407,7 @@ class HttpNode(Node):
"mode",
"timeout",
"headers",
"secret",
"_route_registered",
"_http_client",
)
@@ -411,6 +419,14 @@ class HttpNode(Node):
method: Literal["GET", "POST"] = "POST"
timeout: float = 30.0
headers: dict[str, str] = {}
secret: str = Field(
default="",
description=(
"Shared secret for a webhook, appended to its URL: "
"/hooks/<flow>/<url>/<secret>. Empty leaves the webhook open "
'to anyone. Use {"$secret": "name"} to read it from the store.'
),
)
def __init__(
self,
@@ -442,6 +458,7 @@ class HttpNode(Node):
self.method = cfg.method.upper()
self.timeout = cfg.timeout
self.headers = cfg.headers
self.secret = cfg.secret
self._route_registered = False
self._http_client: httpx.AsyncClient | None = None
@@ -588,6 +605,13 @@ class HttpNode(Node):
:returns: JSON response with trigger result.
:rtype: JSONResponse
"""
if self.secret and not hmac.compare_digest(
str(request.path_params.get("secret", "")).encode(),
self.secret.encode(),
):
# A wrong secret must look like no such hook at all.
return JSONResponse(content={"detail": "Not Found"}, status_code=404)
try:
# Parse request data
if self.method == "GET":
@@ -631,9 +655,13 @@ class HttpNode(Node):
status_code=500,
)
# The secret is a path parameter, not part of the registered path, so
# neither the route table nor the logs below carry its value.
path = f"{self.url}/{{secret}}" if self.secret else self.url
# Create a Starlette Route and add it directly to the app's routes
route = Route(
self.url,
path,
handle_request,
methods=[self.method],
name=self.id,
@@ -645,7 +673,7 @@ class HttpNode(Node):
"Registered %s route for node '%s': %s",
self.method,
self.name,
self.url,
path,
)
def unregister_route(self, app: FastAPI) -> None:
+13 -10
View File
@@ -11,17 +11,18 @@ from __future__ import annotations
import base64
import hashlib
import json
import logging
from pathlib import Path
from typing import Any
from cryptography.fernet import Fernet, InvalidToken
logger = logging.getLogger(__name__)
SECRET_REF = "$secret"
class SecretsUnreadable(RuntimeError):
"""The store exists but does not decrypt with the current SECRET_KEY."""
class SecretNotFound(KeyError):
"""A node asked for a secret that is not in the store."""
@@ -50,13 +51,15 @@ class SecretsStore:
self._fernet.decrypt(self._path.read_bytes())
)
return data
except (InvalidToken, ValueError):
# A changed app key makes existing secrets unreadable.
logger.error(
"Cannot decrypt %s — it was written with a different SECRET_KEY",
self._path,
)
return {}
except (InvalidToken, ValueError) as exc:
# A changed app key makes existing secrets unreadable. Fail loudly:
# flows coming up with silently missing credentials are far harder
# to diagnose than a node that reports why it did not load.
raise SecretsUnreadable(
f"Cannot decrypt {self._path} — it was written with a "
"different SECRET_KEY. Re-encrypt it with "
"scripts/rotate_secret_key.py."
) from exc
def _write(self, data: dict[str, str]) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)