Safe webhook Action handlers

This page walks through a complete worked example of a webhook receiver — a Python Flask app — and explains the safety patterns line by line. The receiver is also shipped at examples/actions/python/webhook_receiver_flask.py. The page also covers custom webhook body templates and the filter syntax you should use when templating user data.

The general modes-and-contract overview lives at Actions: handler safety; read it first if you haven't.

How webhook arguments are passed

How Graywolf hands the sender's data to your endpoint depends on the HTTP method and whether you supplied a custom body template.

GET

Graywolf does not send a request body for GET. The only place to inject sender data is in the URL itself, via template tokens. Always pipe argument values through the |url filter so reserved characters (spaces, ampersands, equals signs) get percent-encoded:

https://example.com/hook?to={{arg.to|url}}&msg={{arg.msg|url}}

Header values support the same token syntax, so you can also forward sender data through a custom header (X-Graywolf-Sender: {{sender-callsign}}). Header values are not automatically URL-encoded; use them for ASCII-only metadata such as a callsign or correlation id.

POST (default form body)

If you leave the body template blank, Graywolf sends an application/x-www-form-urlencoded body. The framework on your receiver will parse it for you and you do not need to template anything. The form fields are:

Form keyValue
actionAction name (the keyword the sender typed).
sender_callsignSender's callsign with SSID.
otp_verifiedtrue or false — whether the TOTP code validated.
otp_credName of the credential that validated the code (empty if OTP was not required).
sourceWhere the trigger arrived from: aprs (live RF/igate) or test (the dashboard's Test dialog).
(arg keys)One form field per kv argument, keyed by the argument name. Freeform mode collapses to a single field named arg holding the entire payload.

POST (custom body template)

Fill in the body template field on the Action when your downstream expects something other than form encoding (e.g. JSON). Graywolf substitutes these tokens before sending the body:

TokenValue
{{action}}Action name.
{{sender-callsign}}Sender's callsign.
{{otp-verified}}true / false.
{{otp-cred}}OTP credential name (empty if OTP not required).
{{source}}aprs / test.
{{arg}}Freeform payload — only present when the Action's argument mode is freeform.
{{arg.<key>}}One per kv argument, looked up by name.

Filters (|json, |url, |html) go after the token name with a pipe: {{arg.msg|json}}. Always pick the filter that matches the surrounding context — raw substitution into a JSON string, URL, or HTML attribute is unsafe. The custom template section below covers the filter list and worked examples.

SMS as a webhook Action

Graywolf POSTs the form-encoded body to your URL. The Action is configured as:

The receiver (this is examples/actions/python/webhook_receiver_flask.py):

from flask import Flask, abort, render_template_string, request
import hmac, os, re, sqlite3

SHARED_SECRET = os.environ["GW_WEBHOOK_SECRET"]
MAX_BODY_BYTES = 8 * 1024
MAX_ARG_LEN = 200
CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")

app = Flask(__name__)

def verify_secret(provided):
    if not provided:
        return False
    return hmac.compare_digest(provided.encode(), SHARED_SECRET.encode())

@app.before_request
def cap_body_size():
    if (request.content_length or 0) > MAX_BODY_BYTES:
        abort(413)

@app.post("/aprs-webhook")
def aprs_webhook():
    if not verify_secret(request.headers.get("X-Graywolf-Auth")):
        abort(401)
    payload = request.form.get("arg", "")
    if len(payload) > MAX_ARG_LEN or CONTROL_CHARS.search(payload):
        abort(400)
    sender = request.form.get("sender_callsign", "")
    with sqlite3.connect(os.environ["GW_RECEIVER_DB"]) as db:
        db.execute(
            "INSERT INTO deliveries (sender, payload) VALUES (?, ?)",
            (sender, payload),
        )
    return render_template_string(
        "<p>received from <strong>{{ sender }}</strong>: {{ payload }}</p>",
        sender=sender, payload=payload,
    )

Explanation

Authenticate the inbound POST. Your webhook URL is in Graywolf's database, and Graywolf is the only thing that knows the shared secret you set in X-Graywolf-Auth. Anything else hitting your endpoint should get a 401 immediately. hmac.compare_digest compares in constant time so an attacker can't measure timing to brute-force the secret one byte at a time.

Cap the body size before parsing it. Graywolf already limits payload bytes, but a bug or downgrade attack on Graywolf's side shouldn't be able to OOM your receiver. The before_request hook bails on Content-Length too high.

Use form-encoded input, not JSON. Graywolf's default body is application/x-www-form-urlencoded, which the framework parses for you with no eval involved. JSON parsing is also fine, but only because Python's json.loads doesn't eval — never eval(request.body).

Revalidate. Same length-and-control-char floor as the shell example. Even though Graywolf already validated, your receiver shouldn't depend on Graywolf staying bug-free forever.

Parameterized SQL. The two ? placeholders are bound by the SQLite driver. The string "INSERT INTO deliveries..." is constant — there is no f-string, no %-format, no concatenation involving payload. SQL injection cannot happen here, regardless of what bytes payload contains.

Auto-escaped templating. Jinja escapes <, >, &, and quotes by default in {{ payload }}. If payload were <script>alert(1)</script>, the rendered HTML would contain &lt;script&gt;, not a real script tag. Never use {{ payload|safe }} or Markup(payload).

Anti-patterns to avoid in webhook receivers

Anti-patternAttack it enables
f"INSERT ... ('{payload}')" SQL injection.
render_template_string("{{ msg|safe }}", ...) Stored XSS.
os.system(f"send {payload}") Shell injection on the receiver host.
requests.get(payload) SSRF — payload becomes a URL the server fetches.
No auth on the endpoint Anyone on the internet can POST as if they were Graywolf.
Logging payload without escaping CR/LF Log forging — fake log entries, terminal escape attacks.

Custom webhook body templates

Most operators should leave the body template blank and use Graywolf's default form encoding — the framework handles escaping correctly and there's nothing to get wrong. If you need a custom template (e.g., a downstream API expects JSON), use the filter syntax:

{
  "to": "{{arg.to|json}}",
  "msg": "{{arg.msg|json}}"
}

The |json filter performs JSON-string escaping so the value can sit safely between the surrounding quotes. Never write:

{
  "msg": "{{arg.msg}}"   ← UNSAFE -- a quote or backslash in the
                           value will break the JSON.
}

Available filters:

FilterUse when templating into
|jsonJSON string contents
|urlURL paths or query strings
|htmlHTML attribute values or text content