Skip to content

fix(security): fold non-canonical IP encodings in SSRF guard (#195)#197

Merged
nicolotognoni merged 1 commit into
mainfrom
fix/ssrf-ip-encodings
Jun 30, 2026
Merged

fix(security): fold non-canonical IP encodings in SSRF guard (#195)#197
nicolotognoni merged 1 commit into
mainfrom
fix/ssrf-ip-encodings

Conversation

@nicolotognoni

Copy link
Copy Markdown
Collaborator

Summary

Implementation

  • Root cause. The guards only recognised canonical IP literals (dotted-quad IPv4, RFC-5952 IPv6). The equivalent encodings that getaddrinfo (Python httpx/websockets) and the WHATWG URL parser (Node) honour were not folded, so they fell through to the "it's a hostname, allow it" branch.
  • Python — new shared getpatter/ssrf.py:
    • resolve_literal_ip(host) folds decimal / octal / hex / short IPv4 (2130706433, 0x7f.0.0.1, 0177.0.0.1, 127.1) via socket.inet_aton, returns None for genuine hostnames.
    • is_internal_ip(addr) checks private / loopback / link-local / reserved / unspecified, and unwraps ipv4_mapped so ::ffff:169.254.169.254 is caught on Python < 3.13 too.
    • Used by both tools/tool_executor.py::_validate_webhook_url (raises) and the server.py::validate_webhook_url mirror (returns bool) — closing the drift the report flagged. services/remote_message.py delegates to the former, so it's covered.
  • TypeScriptsrc/server.ts::validateWebhookUrl: WHATWG already folds the IPv4 spellings, but canonicalises IPv4-mapped IPv6 to its hex form (::ffff:7f00:1), which matched no IPv6 check. New mappedIPv4Octets() unwraps both the dotted and hex forms; isPrivateIPv4() (extracted) range-checks the embedded octets. A mapped public address (::ffff:8.8.8.8) stays allowed.
  • Scope note. The guard remains best-effort against DNS rebinding by design (hostnames resolve at request time) — webhook URLs are trusted SDK-user config, not caller-supplied. Deprecated non-routed forms (::a.b.c.d IPv4-compatible, ::ffff:0:0/96 SIIT) are not folded because modern resolvers don't route them to the embedded IPv4; both SDKs behave identically there.
  • Files: libraries/python/getpatter/ssrf.py (new), getpatter/server.py, getpatter/tools/tool_executor.py, libraries/typescript/src/server.ts, plus tests + CHANGELOG.md (### Security). No new dependencies.

Breaking change?

No. URLs that were silently allowed but should always have been blocked are now rejected (a security tightening, not an API change). Public hostnames, public IPs, and the allow_loopback/allowLoopback consult escape hatch are unchanged.

Test plan

  • Python: pytest tests/ — 2876 passed, 8 skipped, 2 xfailed. New TestSSRFAlternateEncodings (parametrised over every encoding × both validators; RED→GREEN confirmed).
  • TypeScript: npm test (2289 passed) + npm run lint (clean) + new SEC-1b block in tests/security/security.test.ts (8 reject vectors + public-mapped allow).
  • /parity-check intent: identical reject/allow behaviour across SDKs; mechanism may differ (Python inet_aton, TS WHATWG) per sdk-parity.md.
  • E2E smoke — N/A (validator-level, no pipeline/handler change).

Docs updates

  • N/A (internal SSRF plumbing, no new public API surface). CHANGELOG.md ### Security entry added.

The outbound-URL guards (tool webhooks, on_message, consult, MCP) recognised
only canonical IP literals, so alternate spellings of an internal address that
getaddrinfo / the WHATWG URL parser accept slipped past the private-range
checks and connected to the blocked host — including the 169.254.169.254
cloud-metadata endpoint (IAM-credential theft).

Normalise every spelling before the range check:
- decimal / octal / hex / short IPv4 (2130706433, 0x7f.0.0.1, 0177.0.0.1, 127.1)
- IPv4-mapped IPv6 ([::ffff:169.254.169.254], incl. Node's canonical hex form
  ::ffff:a9fe:a9fe)
A mapped public address (::ffff:8.8.8.8) stays allowed.

Python: new shared getpatter/ssrf.py (resolve_literal_ip via socket.inet_aton +
is_internal_ip unwrapping ipv4_mapped) used by both
tools/tool_executor.py::_validate_webhook_url and the
server.py::validate_webhook_url mirror, closing the drift between them.
TypeScript: unwrap IPv4-mapped IPv6 in src/server.ts::validateWebhookUrl.

The guard stays best-effort against DNS rebinding by design (hostnames resolve
at request time) — webhook URLs are trusted SDK-user config, not caller input.

Reported via responsible disclosure (#195).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM
@nicolotognoni nicolotognoni merged commit 879b3ba into main Jun 30, 2026
10 checks passed
nicolotognoni added a commit that referenced this pull request Jun 30, 2026
…#198)

A deep audit after #197 surfaced six confirmed issues, five of them a guard
present in one SDK but missing/weaker in the other.

- (TS) Outbound fetches now fail closed on redirects (redirect: 'error').
  Every guarded fetch (tool webhook, on_message, consult, MCP) followed 3xx by
  default, so an attacker-influenced 302 -> 169.254.169.254 bypassed the SSRF
  guard (validated only the first hop). Python was never affected (httpx
  follow_redirects=False). llm-loop.ts, handler-utils.ts, consult.ts,
  remote-message.ts.
- (TS) Caller DTMF is no longer logged at INFO (PINs/cards/SSNs); dropped to
  DEBUG with no value, matching Python. server.ts.
- Call-id path traversal closed (both SDKs): new shared safe_path_segment /
  safePathSegment folds POSIX and Windows separators and neutralises '..' so a
  forged carrier call id can't escape the recording/call-log dir on Windows.
- Tool/consult response size cap enforced during the read: Python streams +
  bounds (tool_executor.py, consult.py); TS rejects an oversized Content-Length
  up front, with the per-request timeout as the chunked-body backstop.
- (TS) Transfer-destination phone numbers masked in logs (parity with Python).
- Variable sanitiser strips Unicode line separators (U+0085/U+2028/U+2029),
  closing a line-break prompt-injection gap (both SDKs).

Plus a consistency fix: the Python on_message WebSocket guard now routes through
the shared getpatter/ssrf.py so it folds non-canonical IP spellings like its
HTTP sibling.

Tests: +14 Python (tests/security/test_hardening.py), +12 TS (security.test.ts).
Full suites green (Python 2890, TS 2301). No version bump.


Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nicolotognoni nicolotognoni mentioned this pull request Jun 30, 2026
3 tasks
nicolotognoni added a commit that referenced this pull request Jun 30, 2026
Minor bump (new backward-compatible public API accumulated since 0.6.9).
Datestamps the Unreleased CHANGELOG section as 0.7.0 (2026-06-30) and opens a
fresh Unreleased. All three version files move together (getpatter __init__.py,
pyproject.toml, package.json).

Highlights since 0.6.9:
- Inworld integration: corrected TTS pricing + providers.inworld() + InworldLLM
  Realtime Router preset (220+ models) (#199).
- Security: SSRF guard folds non-canonical IP encodings (#197); 6 parity-drift
  hardening fixes — TS redirect SSRF, DTMF/phone log PII, path traversal,
  response cap, Unicode-newline sanitizer (#198).
- Pipeline: fixed false barge-in that self-interrupted the user-turn LLM
  (agent went mute) (#200).
- Audio/Skills/Memory: rate-aware resampler + HPF/AGC front-end, user-supplied
  agent skills, token-aware pipeline compaction (#196).


Claude-Session: https://claude.ai/code/session_01D52f6rBsvzicXg45C6MKVM

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot deleted the fix/ssrf-ip-encodings branch July 1, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant