diff --git a/README.md b/README.md index b5c6aec..77a6056 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/honeypot.py b/honeypot.py index 26a982e..f09acfe 100644 --- a/honeypot.py +++ b/honeypot.py @@ -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 diff --git a/test_profile.py b/test_profile.py index 6fbddb1..c0b2be7 100644 --- a/test_profile.py +++ b/test_profile.py @@ -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'inert-marker' + 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()