Capture bounded SAML envelopes and document proxy limits

This commit is contained in:
StasonJatham committed 2026-10-04 06:57:59 +02:00
1 parent d3b8967aa7
commit 4da3f54e77
3 files changed
+97 -4

No files matched your search

+45 -3
View File
@@ -24,6 +24,7 @@ import sys
import threading
import time
import urllib.parse
import zlib
from trusted_proxy import source as proxy_source, forwarded_client
from evidence import Evidence, EvidenceError, RecordingReader
from nitro import respond as nitro_response
@@ -574,6 +575,34 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.server.engine.backend.upload(body, self.context)
return []
def capture_saml(self, pairs, source):
# Decode protocol envelopes only. Never parse XML, expand entities or authenticate.
for field, value in pairs:
if field.lower() not in ('samlrequest', 'samlresponse'):
continue
try:
decoded = base64.b64decode(value, altchars=b'-_', validate=True)
except (ValueError, binascii.Error):
self.event('saml_decode_error', field=field, source=source, raw_request_retained=True)
continue
self.event('saml_payload', field=field, source=source, encoding='base64', content=decoded)
# HTTP-Redirect binding may carry raw DEFLATE. Bound output before allocation.
if source == 'query' and not decoded.lstrip().startswith(b'<'):
try:
inflater = zlib.decompressobj(-zlib.MAX_WBITS)
inflated = inflater.decompress(decoded, 65537)
if len(inflated) > 65536 or inflater.unconsumed_tail:
self.event('limit', resource='saml_inflation', decoded_payload_retained=True)
elif inflater.eof and not inflater.unused_data:
self.event('saml_payload', field=field, source=source,
encoding='base64+deflate', content=inflated)
else:
self.event('saml_decode_error', field=field, source=source,
reason='incomplete deflate envelope', decoded_payload_retained=True)
except zlib.error:
self.event('saml_decode_error', field=field, source=source,
reason='invalid deflate envelope', decoded_payload_retained=True)
def handle_decoy(self):
engine = self.server.engine
# Trusted Cloudflare/edge headers only when the connecting hop is a configured proxy.
@@ -614,14 +643,27 @@ class Handler(http.server.BaseHTTPRequestHandler):
indicators.append({'source': source, 'kind': 'encoded-content'})
if source.startswith('header:') and len(value) > 8192:
indicators.append({'source': source, 'kind': 'large-header'})
if indicators:
self.event('request_indicators', indicators=indicators,
body_inspected_bytes=min(len(body), 65536), body_inspection_truncated=len(body) > 65536)
try:
url = urllib.parse.urlsplit(self.path)
except ValueError:
self.send_error(400, 'invalid URL')
return
self.capture_saml(fields, 'form')
if len(url.query) <= 65536:
try:
query_fields = urllib.parse.parse_qsl(url.query, max_num_fields=128)
self.capture_saml(query_fields, 'query')
if any(k.lower() in ('samlrequest', 'samlresponse') for k, _ in fields + query_fields):
indicators.append({'source': 'request', 'kind': 'saml-envelope'})
except ValueError:
self.event('limit', resource='query_decoding', raw_request_retained=True)
else:
self.event('limit', resource='query_decoding', raw_request_retained=True)
if url.path == '/logon/LogonPoint/Authentication/GetUserName':
indicators.append({'source': 'uri', 'kind': 'gateway-username-probe'})
if indicators:
self.event('request_indicators', indicators=indicators,
body_inspected_bytes=min(len(body), 65536), body_inspection_truncated=len(body) > 65536)
auth_fields = {key.lower() for key, _ in fields}
if self.command == 'POST' and (url.path.startswith('/nf/auth/') or
url.path in ('/vpn/login', '/vpn/login.html', '/cgi/login') or