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 key | Value |
|---|---|
action | Action name (the keyword the sender typed). |
sender_callsign | Sender's callsign with SSID. |
otp_verified | true or false — whether the TOTP code validated. |
otp_cred | Name of the credential that validated the code (empty if OTP was not required). |
source | Where 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:
| Token | Value |
|---|---|
{{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:
- Type: webhook
- Method: POST
- URL:
https://your-host/aprs-webhook - Headers:
X-Graywolf-Auth: 0123456789abcdef0123456789abcdef - Body template: empty (use the default form encoding)
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 <script>, not a real
script tag. Never use {{ payload|safe }} or
Markup(payload).
Anti-patterns to avoid in webhook receivers
| Anti-pattern | Attack 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:
| Filter | Use when templating into |
|---|---|
|json | JSON string contents |
|url | URL paths or query strings |
|html | HTML attribute values or text content |