fix(security): fold non-canonical IP encodings in SSRF guard (#195)#197
Merged
Conversation
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
4 tasks
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
169.254.169.254cloud-metadata endpoint (IAM-credential theft).Implementation
getaddrinfo(Pythonhttpx/websockets) and the WHATWGURLparser (Node) honour were not folded, so they fell through to the "it's a hostname, allow it" branch.getpatter/ssrf.py:resolve_literal_ip(host)folds decimal / octal / hex / short IPv4 (2130706433,0x7f.0.0.1,0177.0.0.1,127.1) viasocket.inet_aton, returnsNonefor genuine hostnames.is_internal_ip(addr)checks private / loopback / link-local / reserved / unspecified, and unwrapsipv4_mappedso::ffff:169.254.169.254is caught on Python < 3.13 too.tools/tool_executor.py::_validate_webhook_url(raises) and theserver.py::validate_webhook_urlmirror (returns bool) — closing the drift the report flagged.services/remote_message.pydelegates to the former, so it's covered.src/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. NewmappedIPv4Octets()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.::a.b.c.dIPv4-compatible,::ffff:0:0/96SIIT) are not folded because modern resolvers don't route them to the embedded IPv4; both SDKs behave identically there.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/allowLoopbackconsult escape hatch are unchanged.Test plan
pytest tests/— 2876 passed, 8 skipped, 2 xfailed. NewTestSSRFAlternateEncodings(parametrised over every encoding × both validators; RED→GREEN confirmed).npm test(2289 passed) +npm run lint(clean) + newSEC-1bblock intests/security/security.test.ts(8 reject vectors + public-mapped allow).inet_aton, TS WHATWG) persdk-parity.md.Docs updates
CHANGELOG.md### Securityentry added.