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

+25
View File
@@ -84,6 +84,31 @@ configurable evidence quota, a 16MiB request-body limit and eight concurrent req
may reject or normalize traffic before it reaches the collector; unread/rejected bytes beyond
these boundaries cannot be promised as captured.
### Cloudflare / NPM coverage limits
Cloudflare and NPM can remain in front of this collector. Apply WAF/cache exceptions only
to dedicated honeypot hostnames, and preserve trusted client attribution at each hop.
The public IP, certificate, TLS handshake and HTTP/2 or HTTP/3 behavior belong to the edge,
so IP-only or TLS-specific scanners may not identify the simulated NetScaler behind it.
Cloudflare can strip/rewrite headers and reject malformed or DDoS traffic before origin;
NPM changes transport framing too. Recorded wire bytes are what the collector consumed,
not an exact copy of the original internet connection. Correlate Cloudflare security-event
Ray IDs with CF-Ray in captured requests and inspect the dedicated NPM access/error logs
for rejected requests. Those edge logs do not recover rejected request bodies.
DTLS is inactive and not implemented by this HTTP listener. Ordinary Cloudflare HTTP
proxying does not carry UDP/DTLS; adding it later requires an appropriate Spectrum
entitlement and a separate isolated UDP capture/simulation path. No UDP listener or real
NetScaler firmware is enabled by this project. See [Cloudflare proxy limits](https://developers.cloudflare.com/dns/proxy-status/limitations/).
SAMLRequest/SAMLResponse form and query fields are additionally base64-decoded into inert
`saml_payload` artifacts. Redirect-binding DEFLATE output is capped at64KiB; original
request bytes and the decoded compressed envelope remain retained if inflation fails.
Form/query decoding is limited to64KiB and128 fields. XML is never parsed, entities are
never expanded, signatures are never accepted, and no SAML authentication is performed.
These are evidence aids, not proof of a SAML CVE attempt. Oversized accepted requests
remain in raw capture even when optional decoding is skipped.
Checks: `python3 test_nitro.py`, `python3 test_capture.py`, `python3 test_honeypot.py` and
`python3 test_safety.py`. Optional execution tools described below are incompatible with
`--simulation-only` and are not used by the supplied HTTP service.
+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
+27 -1
View File
@@ -1,9 +1,13 @@
"""Run: python3 test_profile.py. Passive fingerprints and inert header/body intake."""
import gzip
import base64
import hashlib
import http.client
import struct
import threading
import xml.etree.ElementTree as ET
import urllib.parse
import zlib
from honeypot import ASSET_MTIME, Engine, Server
from test_private_boundary import PATHS
@@ -47,10 +51,32 @@ try:
assert {'source': 'header:X_Test', 'kind': 'netscaler-log-marker'} in indicator['indicators']
assert {'source': 'body', 'kind': 'encoded-content'} in indicator['indicators']
assert any(e['event'] == 'request_body' and e['request_id'] == indicator['request_id'] for e in events)
xml = b'<Response xmlns="urn:oasis:names:tc:SAML:2.0:protocol">inert-marker</Response>'
form = urllib.parse.urlencode({'SAMLResponse': base64.b64encode(xml).decode()}).encode()
assert request('/cgi/samlauth', 'POST', form, {'Content-Type': 'application/x-www-form-urlencoded'})[0] == 404
saml = next(e for e in reversed(events) if e['event'] == 'saml_payload')
assert saml['source'] == 'form' and saml['evidence']['sha256'] == hashlib.sha256(xml).hexdigest()
raw_body = next(e for e in events if e['event'] == 'request_body' and e['request_id'] == saml['request_id'])
assert raw_body['evidence']['sha256'] == hashlib.sha256(form).hexdigest()
def redirect(value):
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
compressed = compressor.compress(value) + compressor.flush()
return '/cgi/samlauth?' + urllib.parse.urlencode({'SAMLRequest': base64.b64encode(compressed).decode()})
assert request(redirect(xml))[0] == 404
assert any(e['event'] == 'saml_payload' and e['encoding'] == 'base64+deflate'
and e['evidence']['sha256'] == hashlib.sha256(xml).hexdigest() for e in events)
before = len(events)
assert request(redirect(b'A' * 65537))[0] == 404
assert any(e['event'] == 'limit' and e.get('resource') == 'saml_inflation' for e in events[before:])
assert not any(e['event'] == 'saml_payload' and e['encoding'] == 'base64+deflate' for e in events[before:])
assert request('/cgi/samlauth?SAMLResponse=not-base64')[0] == 404
assert any(e['event'] == 'saml_decode_error' for e in events)
assert request('/logon/LogonPoint/Authentication/GetUserName')[0] == 404
assert any({'source': 'uri', 'kind': 'gateway-username-probe'} in e.get('indicators', []) for e in events)
for path in PATHS + ('/.env', '/nsconfig/ns.conf', '/api/events'):
status, _, body = request(path)
assert status == 404 and body == b'Not Found', path
print('PASS: Gateway fingerprint, gzip firmware metadata, auth XML, correlated indicators, private boundary')
print('PASS: Gateway fingerprint, gzip metadata, auth XML, SAML bytes/inflation bounds, indicators, private boundary')
finally:
server.shutdown()
server.server_close()