Capture bounded SAML envelopes and document proxy limits
This commit is contained in:
1 parent
d3b8967aa7
commit
4da3f54e77
3 files changed
+97
-4
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user