Hardening & security¶
A SIP port on the public internet gets scanned within minutes. This recipe collects the layers SIPhon gives you — most are config, a few are one-liners in a script.
1. Drop abuse before it costs you (config)¶
The security: block runs before any SIP parsing or scripting, so banned/garbage
traffic never reaches your handlers:
security:
rate_limit:
window_secs: 10
max_requests: 30 # per source IP per window
ban_duration_secs: 3600
scanner_block:
user_agents: ["sipvicious", "friendly-scanner", "VaxSip", "sipcli"]
trusted_cidrs: ["10.0.0.0/8"] # own infra: never rate-limited, never banned
failed_auth_ban: # auto-ban at accept (UDP/TCP/TLS/WS/SCTP)
threshold: 10 # weighted failures in window_secs → ban
window_secs: 600
ban_duration_secs: 3600
strong_signal_weight: 3 # weight of a high-confidence abuse signal
apiban: # optional: APIBAN community blocklist
api_key: "your-api-key"
interval_secs: 300
ban_ttl_secs: 604800 # 7 days, matching the feed's own release
# policy. 0 = never expire.
trusted_cidrs covers the feed too: an address listed by APIBAN that matches a
trusted CIDR is dropped as the feed is ingested, so it reaches neither the
userspace ACL nor the kernel set. Put your own trunks, monitoring and management
addresses there — a community blocklist has no way to know they're yours, and
the kernel drop is port-agnostic, so a listed management address would cost you
ssh along with the trunk.
How the scoring works¶
failed_auth_ban is a confidence-weighted counter, not a flat fail2ban tally.
Every abuse signal from a source IP adds to a per-IP score within window_secs;
crossing threshold bans the IP for ban_duration_secs. Signals are weighted by
how hard they are to fake:
| Signal | Score |
|---|---|
| 401/407 challenge with no follow-up success | 1 |
| INVITE server-transaction timeout (never ACKed) | 1 |
| Wrong password, or a forged/stale/replayed digest nonce | strong_signal_weight (default 3) |
| Non-SIP bytes on a TCP/TLS stream | strong_signal_weight |
| Failed TLS/WSS/WS handshake | strong_signal_weight |
Scanner User-Agent (scanner_block) |
strong_signal_weight |
Signals arriving over TCP (handshake, malformed bytes) score high because the source IP is validated by the three-way handshake — it can't be spoofed, and a legitimate client never trips them. A successful authentication resets the score to zero, so a subscriber who mistypes a password twice then logs in is never banned, while an IP spraying garbage is banned ~3× faster than one just rattling doorknobs.
Bans are enforced at recv()/accept() — before any SIP parsing — and expire on
their own. trusted_cidrs are exempt from scoring entirely, so put your load
balancers and health checks there.
Drop bans in the kernel
With security.firewall, every ban is also pushed to a
kernel nf_tables set, so abusive sources are dropped before they reach
SIPhon — real defense against volume, not just userspace politeness.
In a script, you can also rate-limit a specific flow:
if not proxy.rate_limit(request, window_secs=1, max_requests=5):
return # silently drop — don't fingerprint the server
2. Drop malformed traffic (script)¶
proxy.sanity_check() runs the RFC 4475 semantic checks (mandatory headers, CSeq,
Content-Length). Drop failures silently so scanners learn nothing:
@proxy.on_request
def route(request):
if not request.in_dialog and not proxy.sanity_check(request):
return # silent drop
...
Silent drop is intentional
Returning from a handler without reply()/relay()/reject() sends no response.
For rate-limit and scanner blocking that's the point — a 403 would confirm the
server exists. Don't "helpfully" reply.
3. Encrypt the signalling (config)¶
listen:
tls: ["0.0.0.0:5061"]
tls:
certificate: "/etc/siphon/tls/cert.pem"
private_key: "/etc/siphon/tls/key.pem"
method: "TLSv1_3"
# mTLS — require and verify client certs (SIP trunks with mutual auth):
verify_client: true
client_ca: "/etc/siphon/tls/client-ca.pem"
method is the minimum TLS version. TLSv1_3 here is a real 1.3-only floor —
it refuses TLS 1.2 peers on the listeners and on outbound connections siphon
dials, so check both sides can do 1.3 before hardening. TLSv1_2 (the default)
negotiates 1.2 or 1.3.
verify_client: true requires a client cert chaining to client_ca (fails closed at
startup if client_ca is missing). It applies to listen.tls and listen.wss.
4. Authenticate subscribers (script + config)¶
if not auth.require_digest(request, realm="example.com"):
return # 401/407 challenge already sent
user = request.auth_user # the authenticated username afterwards
The auth.backend can be static, http (REST credential lookup), database, or
diameter_cx (IMS HSS). For REGISTER-time account-takeover protection, set
registrar.enforce_auth_aor_match: true so a subscriber can't bind a Contact under
someone else's AoR.
5. Verify caller ID — STIR/SHAKEN (script)¶
Sign on egress, verify on ingress at a trunk edge:
from siphon import proxy, stir, log
@proxy.on_request("INVITE")
def on_invite(request):
if request.source_ip_in(["203.0.113.0/24"]): # inbound from a peer
result = stir.verify(request)
if result.verstat == "TN-Validation-Failed":
request.reply(438, "Invalid Identity Header") # RFC 8224 §6.2.2
return
stir.apply_verstat(request, result) # convey downstream
else: # outbound
origid = stir.sign(request, attestation="A")
request.record_route()
request.relay()
Needs a stir: block with signing + verification configured.
The source_ip_in([...]) above hardcodes the peer's CIDR. If that peer is already
a gateway group (a trunk you health-probe), test membership by group name instead
so you never maintain two copies of the address list — see the next section.
5.5. Direction & trust — from_gateway¶
request.from_gateway("group") (and call.from_gateway("group") in a B2BUA) returns
True when the message's source IP is one of the resolved addresses of the named
gateway group. It's SIPhon's equivalent of Kamailio ds_is_from_list() /
OpenSIPS ds_is_in_list() — a routing-direction predicate that replaces hardcoded
source CIDRs with the trunk list you already maintain under gateway.groups.
from siphon import proxy, gateway
@proxy.on_request("INVITE")
def route(request):
if request.from_gateway("teams"):
# Inbound leg from Microsoft Teams — trust it, forward to the PBX.
request.relay("sip:pbx.internal:5060")
else:
# Outbound leg from the PBX — send to Teams.
request.relay(gateway.select("teams").uri)
It matches on IP only (source port ignored) against every resolved address in
the group, so a hostname that round-robins across many IPs — Teams'
sip/sip2/sip3.pstnhub.microsoft.com, a carrier's rotating trunk — matches on any
of them. The member set is cached and refreshed on the health-probe cycle, so the
predicate never resolves DNS on the request path.
Trustworthy on TCP/TLS/WS/WSS, a hint on UDP
On connection-oriented transports the source IP is verified by the handshake, so
from_gateway is a sound authorization signal. On UDP the source IP is
spoofable — treat from_gateway there as a best-effort direction hint, and
gate real trust decisions on TLS/mTLS or digest/AKA auth.
6. IMS access security — IPsec (Gm)¶
For a P-CSCF, SIPhon does full 3GPP TS 33.203 sec-agree: parse Security-Client,
run AKA, install kernel IPsec SAs, and route MT requests back over the flow. It's a
substantial flow — see examples/ims_pcscf.py
and the ipsec: config block. The SA lifetime tracks the registration lifetime
automatically.
Checklist¶
- [ ]
security.failed_auth_ban+scanner_blockon, infra intrusted_cidrs - [ ]
proxy.sanity_check()on out-of-dialog requests, silent-drop failures - [ ] TLS (and mTLS for trunks); subscriber-facing access over TLS/WSS
- [ ] Digest auth on REGISTER (+
enforce_auth_aor_match) - [ ] STIR/SHAKEN at PSTN edges; IPsec at IMS Gm
- [ ] Alert on the security metrics (see Monitoring)
See also¶
- Real example:
examples/stir_shaken.py,examples/ims_pcscf.py. - Reference config:
siphon.yaml.