From 167788223dcf6eab05b42cfec25c432ff46beb56 Mon Sep 17 00:00:00 2001 From: JVQ Date: Sat, 3 Oct 2026 13:05:14 +0200 Subject: [PATCH 1/3] Document selected-fetch URL preflight Add the tested external webhook example and correct the HMAC secret reference documentation. Scope the policy to configured fetch requests and document fail-closed decisions, TLS, and signature expiration. Developed with AI assistance; associated with the IsMalicious provider. Signed-off-by: JVQ --- docs/toolhive/guides-cli/webhooks.mdx | 66 +++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/docs/toolhive/guides-cli/webhooks.mdx b/docs/toolhive/guides-cli/webhooks.mdx index 0e552924..6eef0981 100644 --- a/docs/toolhive/guides-cli/webhooks.mdx +++ b/docs/toolhive/guides-cli/webhooks.mdx @@ -101,14 +101,14 @@ mutating: ### Webhook fields -| Field | Required | Description | -| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | Yes | Unique identifier for this webhook. Used for deduplication when merging multiple config files. | -| `url` | Yes | HTTPS endpoint to call. Plain HTTP is accepted for in-cluster or development use (see `insecure_skip_verify` below). | -| `failure_policy` | Yes | `fail` (deny the request on webhook error) or `ignore` (allow through on error). | -| `timeout` | No | Maximum wait time for a response. Accepts duration strings like `5s` or `30s`. Minimum: `1s`, maximum: `30s`. Default: `10s`. | -| `tls_config` | No | TLS options for the webhook HTTP client (see below). | -| `hmac_secret_ref` | No | Environment variable name containing an HMAC secret for payload signing. **Not yet implemented** - accepted in config but currently has no effect. | +| Field | Required | Description | +| ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Unique identifier for this webhook. Used for deduplication when merging multiple config files. | +| `url` | Yes | HTTPS endpoint to call. Plain HTTP is accepted for in-cluster or development use (see `insecure_skip_verify` below). | +| `failure_policy` | Yes | `fail` (deny the request on webhook error) or `ignore` (allow through on error). | +| `timeout` | No | Maximum wait time for a response. Accepts duration strings like `5s` or `30s`. Minimum: `1s`, maximum: `30s`. Default: `10s`. | +| `tls_config` | No | TLS options for the webhook HTTP client (see below). | +| `hmac_secret_ref` | No | Absolute path to a file containing an HMAC secret, or a secret name resolved by your configured secrets provider. ToolHive signs each payload with HMAC-SHA256. | ### TLS configuration @@ -172,6 +172,56 @@ thv run fetch \ ToolHive validates all webhook configurations at startup and exits with an error if any are invalid, so configuration problems surface before the server starts. +## Example: check selected fetch URLs before execution + +The external +[IsMalicious gateway filter](https://github.com/hexablob/ismalicious-gateway-filters) +implements the validating webhook contract. It checks the original HTTP(S) URL +argument of selected fetch tools before the tool call reaches the MCP server. +Use this example when you want an external URL-reputation policy to decide +whether to execute a fetch. + +1. Deploy the service behind a TLS endpoint reachable from ToolHive. Supply + `ISMALICIOUS_API_KEY` and `ISMALICIOUS_API_SECRET` through your secret + manager. Set `ISMALICIOUS_FETCH_TOOLS` to a JSON map of tool names to their + URL argument, such as `{"fetch":"url"}`. Match the actual tool name and + argument. +1. Set `ISMALICIOUS_WEBHOOK_SECRET` on the service. Mount the same secret as a + file readable by ToolHive. The receiver verifies the signature and rejects + timestamps outside a five-minute window. +1. Save this validating webhook configuration: + +```yaml title="ismalicious-webhooks.yaml" +validating: + - name: ismalicious-fetch-preflight + url: https:///toolhive + failure_policy: fail + timeout: 20s + hmac_secret_ref: /etc/toolhive/ismalicious-webhook-secret +``` + +4. Run the fetch server with the configuration: + +```bash +thv run --webhook-config ismalicious-webhooks.yaml +``` + +The service echoes the request's `uid` and `version`, admits a valid `allow` +decision, and denies `warn`, `block`, an invalid decision, or an API failure. +Unselected tools are admitted without a reputation check. Configure +`failure_policy: fail` so connection failures to the service also deny the +request. Keep TLS verification enabled; use `ca_bundle_path` for a private CA. + +This example checks request arguments. It does not inspect returned tool +content, streamed responses, or tool descriptions. A URL-reputation `allow` +decision can include unknown reputation and does not prove the destination +benign. The reputation service checks the URL without fetching it. + +The external repository includes tests with ToolHive's signing client and +validating middleware. Synthetic decisions verify that denied requests do not +reach the next handler; they do not measure reputation accuracy. Test the +selected fetch tool and your deployed webhook configuration before use. + ## Failure policies The `failure_policy` field controls what happens when ToolHive cannot reach the From 520ca2aedb97543a844739fb2722dcde92cd11bb Mon Sep 17 00:00:00 2001 From: JVQ Date: Tue, 6 Oct 2026 15:50:27 +0200 Subject: [PATCH 2/3] Document a self-contained URL allowlist Replace the provider-specific preflight with an inline Python webhook and allowed/denied ToolHive calls, as requested in review. Use scoped local CA trust and document request-only scope. Keep the independent HMAC secret-reference correction. Developed and validated with AI assistance. Signed-off-by: JVQ --- docs/toolhive/guides-cli/webhooks.mdx | 201 ++++++++++++++++++++------ 1 file changed, 160 insertions(+), 41 deletions(-) diff --git a/docs/toolhive/guides-cli/webhooks.mdx b/docs/toolhive/guides-cli/webhooks.mdx index 6eef0981..d984fe21 100644 --- a/docs/toolhive/guides-cli/webhooks.mdx +++ b/docs/toolhive/guides-cli/webhooks.mdx @@ -172,55 +172,174 @@ thv run fetch \ ToolHive validates all webhook configurations at startup and exits with an error if any are invalid, so configuration problems surface before the server starts. -## Example: check selected fetch URLs before execution - -The external -[IsMalicious gateway filter](https://github.com/hexablob/ismalicious-gateway-filters) -implements the validating webhook contract. It checks the original HTTP(S) URL -argument of selected fetch tools before the tool call reaches the MCP server. -Use this example when you want an external URL-reputation policy to decide -whether to execute a fetch. - -1. Deploy the service behind a TLS endpoint reachable from ToolHive. Supply - `ISMALICIOUS_API_KEY` and `ISMALICIOUS_API_SECRET` through your secret - manager. Set `ISMALICIOUS_FETCH_TOOLS` to a JSON map of tool names to their - URL argument, such as `{"fetch":"url"}`. Match the actual tool name and - argument. -1. Set `ISMALICIOUS_WEBHOOK_SECRET` on the service. Mount the same secret as a - file readable by ToolHive. The receiver verifies the signature and rejects - timestamps outside a five-minute window. -1. Save this validating webhook configuration: - -```yaml title="ismalicious-webhooks.yaml" +## Example: allow fetch requests to specific hosts + +This self-contained example allows the `fetch` tool to request HTTP(S) URLs on +`example.com`. It denies other hosts, other tools, URLs with credentials or +non-default ports, and malformed requests. The webhook checks the requested URL +without fetching it. + +You need Python 3.10 or later, OpenSSL, and a container runtime supported by +ToolHive. Run the commands in the same directory on your host machine. + +### Create the webhook service + +Save this Python standard-library service as `url_allowlist.py`: + +```python title="url_allowlist.py" +import json +import ssl +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +ALLOWED_HOSTS = {"example.com"} +# These methods let an MCP client initialize and discover the fetch tool. +ALLOWED_METHODS = {"initialize", "notifications/initialized", "ping", "tools/list"} + + +def allow_request(request): + if request.get("jsonrpc") != "2.0": + return False + method = request.get("method") + if method in ALLOWED_METHODS: + return True + if method != "tools/call": + return False + params = request.get("params") + if not isinstance(params, dict) or params.get("name") != "fetch": + return False + arguments = params.get("arguments") + if not isinstance(arguments, dict): + return False + url = arguments.get("url") + if not isinstance(url, str) or any(c.isspace() for c in url) or "\\" in url: + return False + try: + parsed = urlsplit(url) + return ( + parsed.scheme in {"http", "https"} + and parsed.hostname in ALLOWED_HOSTS + and parsed.username is None + and parsed.password is None + and parsed.port in {None, {"http": 80, "https": 443}[parsed.scheme]} + ) + except ValueError: + return False + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/validate": + self.send_error(404) + return + try: + size = int(self.headers.get("Content-Length", "0")) + if not 0 < size <= 1024 * 1024: + raise ValueError("Invalid body size") + payload = json.loads(self.rfile.read(size)) + if ( + not isinstance(payload, dict) + or payload.get("version") != "v0.1.0" + or not isinstance(payload.get("uid"), str) + or not payload["uid"] + or not isinstance(payload.get("mcp_request"), dict) + ): + raise ValueError("Invalid webhook request") + allowed = allow_request(payload["mcp_request"]) + except (ValueError, TypeError): + self.send_error(422) + return + response = { + "version": payload["version"], + "uid": payload["uid"], + "allowed": allowed, + } + if not allowed: + response["reason"] = "url_allowlist_denied" + body = json.dumps(response).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain("webhook.crt", "webhook.key") + server = ThreadingHTTPServer(("127.0.0.1", 9443), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + server.serve_forever() +``` + +### Start the service with HTTPS + +Create a one-day certificate for the local webhook. ToolHive will trust this +certificate only through the webhook's `ca_bundle_path`; you don't need to add +it to your system's trust store. + +```bash +openssl req -x509 -newkey rsa:2048 -noenc -days 1 \ + -keyout webhook.key -out webhook.crt \ + -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1" +python3 url_allowlist.py +``` + +Keep this terminal open. The service listens only on the host's loopback +interface. For a shared deployment, use your own HTTPS endpoint and authenticate +requests, for example with HMAC signing or mutual TLS. + +### Connect ToolHive to the webhook + +In a second terminal in the same directory, create the webhook configuration. +The absolute CA path lets the ToolHive proxy find the certificate when it runs +in the background: + +```bash +cat > allowlist-webhooks.yaml </toolhive + - name: fetch-url-allowlist + url: https://127.0.0.1:9443/validate failure_policy: fail - timeout: 20s - hmac_secret_ref: /etc/toolhive/ismalicious-webhook-secret + timeout: 5s + tls_config: + ca_bundle_path: ${PWD}/webhook.crt +EOF +thv run fetch --name allowlist-fetch --webhook-config allowlist-webhooks.yaml +``` + +`failure_policy: fail` also denies requests if the webhook becomes unavailable. +TLS certificate verification remains enabled. + +### Try an allowed and a denied request + +Call the allowed host: + +```bash +thv mcp call fetch --server allowlist-fetch \ + --args '{"url":"https://example.com"}' ``` -4. Run the fetch server with the configuration: +The fetch tool returns content from `example.com`. Call a host outside the +allowlist: ```bash -thv run --webhook-config ismalicious-webhooks.yaml +thv mcp call fetch --server allowlist-fetch \ + --args '{"url":"https://example.org"}' ``` -The service echoes the request's `uid` and `version`, admits a valid `allow` -decision, and denies `warn`, `block`, an invalid decision, or an API failure. -Unselected tools are admitted without a reputation check. Configure -`failure_policy: fail` so connection failures to the service also deny the -request. Keep TLS verification enabled; use `ca_bundle_path` for a private CA. - -This example checks request arguments. It does not inspect returned tool -content, streamed responses, or tool descriptions. A URL-reputation `allow` -decision can include unknown reputation and does not prove the destination -benign. The reputation service checks the URL without fetching it. - -The external repository includes tests with ToolHive's signing client and -validating middleware. Synthetic decisions verify that denied requests do not -reach the next handler; they do not measure reputation accuracy. Test the -selected fetch tool and your deployed webhook configuration before use. +ToolHive returns `Request denied by policy` and the command exits with a +non-zero status. The denied call never reaches the fetch server. To allow +additional hosts, add their exact names to `ALLOWED_HOSTS` and restart the +Python service. + +The policy checks the initial URL argument. Redirect destinations, DNS changes, +and returned content require separate controls, such as an egress policy on the +fetch server. An allowed host is not a guarantee that its content is safe. + +When you're finished, run `thv rm allowlist-fetch` and stop the Python service +with Ctrl+C. Delete the local certificate and private key if you no longer need +them. ## Failure policies From 54906c63d0d9cafd7c2706dc078bec9095eee6af Mon Sep 17 00:00:00 2001 From: JVQ Date: Thu, 8 Oct 2026 09:39:07 +0200 Subject: [PATCH 3/3] Use a portable OpenSSL certificate flag Replace -noenc with -nodes in the local webhook certificate command, as requested in review, so it works with macOS LibreSSL and OpenSSL. Developed and validated with AI assistance. Signed-off-by: JVQ --- docs/toolhive/guides-cli/webhooks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/toolhive/guides-cli/webhooks.mdx b/docs/toolhive/guides-cli/webhooks.mdx index d984fe21..1c098e4b 100644 --- a/docs/toolhive/guides-cli/webhooks.mdx +++ b/docs/toolhive/guides-cli/webhooks.mdx @@ -279,7 +279,7 @@ certificate only through the webhook's `ca_bundle_path`; you don't need to add it to your system's trust store. ```bash -openssl req -x509 -newkey rsa:2048 -noenc -days 1 \ +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ -keyout webhook.key -out webhook.crt \ -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1" python3 url_allowlist.py