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
+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
|
||||
|
||||
Reference in new issue
Block a user