#!/usr/bin/env bash set -uo pipefail VERSION="1.0.0" usage() { printf '%s\n' \ "n8n Production Webhook Ingress Check ${VERSION}" \ "" \ "Usage:" \ " bash n8n-webhook-ingress-check.sh --confirm-no-irreversible-side-effects https://n8n.example.com/webhook/path [POST|GET]" \ "" \ "The confirmation means the target is a sandbox or cloned webhook, or every" \ "irreversible downstream side effect has been disabled before the request." \ "" \ "The checker sends no credentials and refuses URLs containing user info," \ "query strings, fragments, cleartext remote HTTP, or /webhook-test/." } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then usage exit 0 fi if [[ "$#" -lt 2 || "$#" -gt 3 ]]; then usage >&2 exit 64 fi if [[ "$1" != "--confirm-no-irreversible-side-effects" ]]; then printf 'ERROR: Refusing to trigger a workflow without the exact safety confirmation.\n' >&2 usage >&2 exit 64 fi TARGET_URL="$2" METHOD="${3:-POST}" METHOD="$(printf '%s' "$METHOD" | tr '[:lower:]' '[:upper:]')" case "$METHOD" in POST|GET) ;; *) printf 'ERROR: This checker supports only POST or GET.\n' >&2 exit 64 ;; esac if [[ "$TARGET_URL" =~ ^https:// ]]; then : elif [[ "$TARGET_URL" =~ ^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?(/|$) ]]; then : elif [[ "$TARGET_URL" =~ ^http:// ]]; then printf 'ERROR: Remote production webhooks should use HTTPS. Refusing cleartext URL.\n' >&2 exit 64 else printf 'ERROR: Provide a complete HTTPS production-webhook URL.\n' >&2 exit 64 fi if [[ "$TARGET_URL" =~ ^https?://[^/]*@ ]]; then printf 'ERROR: User information in URLs can leak credentials. Remove it and retry.\n' >&2 exit 64 fi if [[ "$TARGET_URL" == *"?"* || "$TARGET_URL" == *"#"* ]]; then printf 'ERROR: Query strings and fragments are intentionally refused. Use a secret-free path-only probe.\n' >&2 exit 64 fi if [[ "$TARGET_URL" == *"/webhook-test"* ]]; then printf 'ERROR: This checker is for the production webhook path, not /webhook-test/.\n' >&2 exit 64 fi if ! command -v curl >/dev/null 2>&1; then printf 'ERROR: curl is required.\n' >&2 exit 69 fi if command -v uuidgen >/dev/null 2>&1; then PROBE_ID="ard-$(uuidgen | tr '[:upper:]' '[:lower:]')" else PROBE_ID="ard-$(date -u '+%Y%m%dT%H%M%SZ')-${RANDOM}${RANDOM}" fi SENT_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" if ! WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/n8n-webhook-ingress-check.XXXXXX")"; then printf 'ERROR: Could not create a private temporary directory.\n' >&2 exit 73 fi trap 'rm -rf "$WORK_DIR"' EXIT RESPONSE_HEADERS="$WORK_DIR/response.headers" RESPONSE_BODY="$WORK_DIR/response.body" RESPONSE_META="$WORK_DIR/response.meta" RESPONSE_ERROR="$WORK_DIR/response.error" CURL_ARGS=( --silent --show-error --connect-timeout 8 --max-time 20 --max-redirs 0 --request "$METHOD" --header "Accept: application/json, text/plain, */*" --header "X-Agent-Rescue-Probe: ${PROBE_ID}" --dump-header "$RESPONSE_HEADERS" --output "$RESPONSE_BODY" --write-out $'http_code=%{http_code}\ncontent_type=%{content_type}\nredirect_url_present=%{redirect_url}\nssl_verify_result=%{ssl_verify_result}\ntime_connect=%{time_connect}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\n' ) if [[ "$METHOD" == "POST" ]]; then PROBE_BODY='{"agent_rescue_probe":true,"correlation_id":"'"$PROBE_ID"'","sent_at":"'"$SENT_AT"'"}' CURL_ARGS+=( --header "Content-Type: application/json" --data "$PROBE_BODY" ) fi curl "${CURL_ARGS[@]}" "$TARGET_URL" >"$RESPONSE_META" 2>"$RESPONSE_ERROR" CURL_STATUS=$? printf '# n8n production webhook ingress evidence\n\n' printf -- '- Checked at: %s\n' "$SENT_AT" printf -- '- Checker: %s\n' "$VERSION" printf -- '- Method: %s\n' "$METHOD" printf -- '- Correlation ID: `%s`\n' "$PROBE_ID" printf -- '- Target URL: intentionally omitted from the report\n' printf -- '- Safety confirmation: no irreversible side effects\n\n' if [[ "$CURL_STATUS" -ne 0 ]]; then printf '## Transport result\n\n' printf -- '- curl exit code: %s\n' "$CURL_STATUS" printf -- '- HTTP response: none or incomplete\n\n' printf '**Interpretation:** start with DNS, TLS, reverse proxy, firewall, and deployed-runtime reachability. Compare the timestamp and correlation ID with ingress logs.\n\n' else HTTP_CODE="$(sed -n 's/^http_code=//p' "$RESPONSE_META")" CONTENT_TYPE="$(sed -n 's/^content_type=//p' "$RESPONSE_META")" BODY_BYTES="$(wc -c <"$RESPONSE_BODY" | tr -d '[:space:]')" printf '## Transport result\n\n' printf '```text\n' sed -n '1,7p' "$RESPONSE_META" | sed -E 's/^(redirect_url_present=).+$/\1[present]/' grep -Eai '^(HTTP/|content-type:|server:|via:|retry-after:|x-request-id:|x-correlation-id:)' "$RESPONSE_HEADERS" | sed -n '1,20p' printf 'response_body_bytes=%s\n' "$BODY_BYTES" if command -v shasum >/dev/null 2>&1; then printf 'response_body_sha256=%s\n' "$(shasum -a 256 "$RESPONSE_BODY" | awk '{print $1}')" fi printf 'response_body=[not displayed]\n' printf '```\n\n' case "$HTTP_CODE" in 2??) printf '**Interpretation:** the HTTP layer accepted the request. A %s response does not prove that n8n created an execution or that the downstream business outcome occurred.\n\n' "$HTTP_CODE" ;; 3??) printf '**Interpretation:** the webhook redirected. The checker does not follow redirects; verify the canonical production URL and reverse-proxy route.\n\n' ;; 401|403) printf '**Interpretation:** an authentication or gateway policy refused the request before a successful workflow outcome was proven.\n\n' ;; 404) printf '**Interpretation:** the path was not found or the production webhook is not registered. Verify the published workflow, exact production path, method, and receiving instance.\n\n' ;; 405) printf '**Interpretation:** the route rejected %s. Compare the Webhook node method with the caller method.\n\n' "$METHOD" ;; 408|429) printf '**Interpretation:** the request was timed out or rate limited before workflow success was proven. Preserve retry and idempotency evidence before replaying.\n\n' ;; 5??) printf '**Interpretation:** a gateway, n8n process, or worker failed while handling the request. Search logs and executions using the timestamp and correlation ID.\n\n' ;; 000|"") printf '**Interpretation:** no HTTP response was received. Start with DNS, TLS, proxy, and runtime reachability.\n\n' ;; *) printf '**Interpretation:** HTTP %s is transport evidence only. Match it to the n8n execution and final downstream state.\n\n' "$HTTP_CODE" ;; esac if grep -Eaiq 'webhook.{0,80}not registered|not registered.{0,80}webhook' "$RESPONSE_BODY"; then printf -- '- Classified body signal: webhook registration error detected; body omitted.\n' fi if grep -Eaiq 'workflow could not be started' "$RESPONSE_BODY"; then printf -- '- Classified body signal: workflow start error detected; body omitted.\n' fi if grep -Eaiq 'webhook.{0,80}not found|not found.{0,80}webhook' "$RESPONSE_BODY"; then printf -- '- Classified body signal: webhook path error detected; body omitted.\n' fi printf '\n' fi printf '## Required correlation\n\n' printf '%s\n' \ '1. Search the reverse-proxy or load-balancer log for the correlation ID and UTC timestamp.' \ '2. Search n8n Executions for the same time and workflow. Record the execution ID, or explicitly record that no execution exists.' \ '3. If no execution exists, verify the exact production URL and method, published or active state, webhook registration, instance hostname, and queue-mode routing.' \ '4. If an execution exists, inspect the first failed node, response mode, payload size, binary-data handling, worker availability, timeout, and memory evidence.' \ '5. Verify the intended external state separately. An HTTP response and a green n8n execution are not proof that the CRM, message, file, booking, or payment changed.' printf '\n## Safety boundary\n\n' printf '%s\n' \ 'The checker accepts no token, password, cookie, API key, custom authorization header, query string, or customer payload.' \ 'Run it only against a cloned or sandbox webhook, or after disabling every irreversible downstream side effect.' exit "$CURL_STATUS"