55Adjusted code for asyncio, aiohttp and redis (asynchronous support) by t3chn0m4g3
66"""
77
8+ import argparse
89import asyncio
910import json
11+ import re
12+ import time
13+ from urllib .parse import urlparse
1014
1115import redis .asyncio as redis
1216from aiohttp import web
1317
14- # Configuration
15- # Within T-Pot: redis_url = 'redis://map_redis:6379'
16- #redis_url = 'redis://127.0.0.1:6379'
17- #web_port = 1234
18- redis_url = 'redis://map_redis:6379'
19- web_port = 64299
20- version = 'Attack Map Server 3.0.1'
18+ from demo_events import DEMO_BANNER , DemoEventGenerator , add_demo_arguments
2119
20+ # Configuration defaults (override via CLI flags, HANDOFF-v2 D21)
21+ DEFAULT_REDIS_URL = 'redis://map_redis:6379'
22+ DEFAULT_WEB_HOST = '127.0.0.1' # loopback by default; containers pass --host 0.0.0.0
23+ DEFAULT_WEB_PORT = 64299
24+
25+ # WebSocket hardening (security review 2026-09-02): authentication is the
26+ # reverse proxy's job (T-Pot nginx, TLS + basic auth), but the origin check
27+ # must live here — browsers attach cached credentials to cross-site WebSocket
28+ # handshakes and WebSockets are not subject to CORS, so without it any website
29+ # open in an operator's browser could read the live attack feed (CSWSH).
30+ MAX_WS_CLIENTS = 64
31+ WS_MAX_MSG_SIZE = 64 * 1024
32+ WS_HEARTBEAT_S = 30
33+ WS_SEND_TIMEOUT_S = 5
34+
35+ version = 'Attack Map Server 4.0.0'
36+
37+ redis_url = DEFAULT_REDIS_URL
38+
39+
40+ def read_csp_from_index (path = 'static/index.html' ):
41+ """The <meta> CSP in index.html is the single source of truth; the HTTP
42+ header mirrors it (plus frame-ancestors, which meta CSP cannot express)."""
43+ try :
44+ with open (path , encoding = 'utf-8' ) as fh :
45+ head = fh .read (8192 )
46+ m = re .search (
47+ r'http-equiv="Content-Security-Policy"\s+content="([^"]+)"' , head )
48+ if m :
49+ return m .group (1 )
50+ except OSError :
51+ pass
52+ return None
53+
54+
55+ CSP_HEADER = read_csp_from_index ()
56+
57+
58+ @web .middleware
59+ async def security_headers (request , handler ):
60+ # D31 — the .mjs MIME guarantee. Public API only: FileResponse.prepare() guesses
61+ # the type only when Content-Type is not already set (aiohttp 3.14.3
62+ # web_fileresponse.py:385), and its guesser is a module-private MimeTypes()
63+ # instance that mimetypes.add_type() never reaches (line 52). Do not touch
64+ # aiohttp internals.
65+ resp = await handler (request )
66+ if request .path .endswith (".mjs" ) and isinstance (resp , web .FileResponse ):
67+ resp .content_type = "text/javascript"
68+ # Security headers (2026-09-02): the CSP header mirrors the meta tag
69+ # (header AND meta are enforced as an intersection — identical policies,
70+ # plus frame-ancestors which only works as a header). 'self' instead of
71+ # 'none' so same-origin embedding behind T-Pot's nginx stays possible.
72+ if CSP_HEADER :
73+ resp .headers .setdefault ('Content-Security-Policy' ,
74+ CSP_HEADER + "; frame-ancestors 'self'" )
75+ resp .headers .setdefault ('X-Content-Type-Options' , 'nosniff' )
76+ resp .headers .setdefault ('Referrer-Policy' , 'no-referrer' )
77+ return resp
78+
79+
80+ async def broadcast (websockets , data ):
81+ """Send one message to every connected client, isolating slow consumers:
82+ a client that cannot take the message within WS_SEND_TIMEOUT_S (stuck TCP,
83+ dead proxy connection) is closed and dropped instead of stalling the
84+ broadcast loop for everyone (the old bare gather() awaited the slowest
85+ client each tick, with nginx read timeouts of two hours)."""
86+ async def send_one (ws ):
87+ try :
88+ await asyncio .wait_for (ws .send_str (data ), timeout = WS_SEND_TIMEOUT_S )
89+ return None
90+ except Exception :
91+ return ws
92+
93+ results = await asyncio .gather (* [send_one (ws ) for ws in list (websockets )])
94+ for dead in filter (None , results ):
95+ if dead in websockets :
96+ websockets .remove (dead )
97+ try :
98+ await asyncio .wait_for (dead .close (), timeout = 1 )
99+ except Exception :
100+ pass
101+ print (f"[-] Dropped unresponsive WebSocket client. Clients active: { len (websockets )} " )
22102
23103
24104async def redis_subscriber (websockets ):
@@ -32,12 +112,12 @@ async def redis_subscriber(websockets):
32112 # Subscribe to a Redis channel
33113 channel = "attack-map-production"
34114 await pubsub .subscribe (channel )
35-
115+
36116 # Print reconnection message if we were previously disconnected
37117 if was_disconnected :
38118 print ("[*] Redis connection re-established" )
39119 was_disconnected = False
40-
120+
41121 # Start a loop to listen for messages on the channel
42122 while True :
43123 message = await pubsub .get_message (ignore_subscribe_messages = True )
@@ -46,8 +126,8 @@ async def redis_subscriber(websockets):
46126 # Only take the data and forward as JSON to the connected websocket clients
47127 # Decode bytes directly instead of load/dump cycle
48128 json_data = message ['data' ].decode ('utf-8' )
49- # Process all connected websockets in parallel
50- await asyncio . gather ( * [ ws . send_str ( json_data ) for ws in websockets ], return_exceptions = True )
129+ # Parallel send with slow-consumer isolation
130+ await broadcast ( websockets , json_data )
51131 except :
52132 print ("Something went wrong while sending JSON data." )
53133 else :
@@ -57,17 +137,63 @@ async def redis_subscriber(websockets):
57137 was_disconnected = True
58138 await asyncio .sleep (5 )
59139
140+
141+ async def demo_publisher (websockets , args ):
142+ """Demo mode (D20): synthetic events straight to the connected websockets.
143+ No Redis, no Elasticsearch. Banner at start and every 60 s; every message
144+ carries "demo": true."""
145+ generator = DemoEventGenerator (seed = args .demo_seed , scenario = args .demo_scenario )
146+ interval = 1.0 / args .demo_rate if args .demo_rate > 0 else 0.5
147+ if args .demo_scenario == "flood" :
148+ interval = min (interval , 0.02 )
149+
150+ async def send (message ):
151+ await broadcast (websockets , json .dumps (message ))
152+
153+ print (DEMO_BANNER )
154+ last_banner = time .monotonic ()
155+ last_stats = time .monotonic ()
156+
157+ for _ in range (args .demo_burst ):
158+ await send (generator .next_event ())
159+
160+ while True :
161+ await send (generator .next_event ())
162+ now = time .monotonic ()
163+ if now - last_stats >= 10 :
164+ await send (generator .stats_message ())
165+ last_stats = now
166+ if now - last_banner >= 60 :
167+ print (DEMO_BANNER )
168+ last_banner = now
169+ await asyncio .sleep (interval )
170+
60171async def my_websocket_handler (request ):
61- ws = web .WebSocketResponse ()
172+ # CSWSH protection: a browser handshake carries an Origin header, and it
173+ # must match the host the request was addressed to. Non-browser clients
174+ # without an Origin stay allowed — authentication is the reverse proxy's
175+ # job, this check only stops foreign websites riding the browser session.
176+ origin = request .headers .get ('Origin' )
177+ if origin is not None :
178+ if urlparse (origin ).netloc != request .headers .get ('Host' , '' ):
179+ raise web .HTTPForbidden (text = 'WebSocket origin not allowed' )
180+
181+ if len (request .app ['websockets' ]) >= MAX_WS_CLIENTS :
182+ raise web .HTTPServiceUnavailable (text = 'WebSocket client limit reached' )
183+
184+ ws = web .WebSocketResponse (max_msg_size = WS_MAX_MSG_SIZE , heartbeat = WS_HEARTBEAT_S )
62185 await ws .prepare (request )
63186 request .app ['websockets' ].append (ws )
64187 print (f"[*] New WebSocket connection opened. Clients active: { len (request .app ['websockets' ])} " )
65- async for msg in ws :
66- if msg .type == web .WSMsgType .TEXT :
67- await ws .send_str (msg .data )
68- elif msg .type == web .WSMsgType .ERROR :
69- print (f'WebSocket connection closed with exception { ws .exception ()} ' )
70- request .app ['websockets' ].remove (ws )
188+ try :
189+ async for msg in ws :
190+ # The feed is one-way: incoming frames are drained, never echoed
191+ # (the old echo served no client and only reflected input).
192+ if msg .type == web .WSMsgType .ERROR :
193+ print (f'WebSocket connection closed with exception { ws .exception ()} ' )
194+ finally :
195+ if ws in request .app ['websockets' ]:
196+ request .app ['websockets' ].remove (ws )
71197 print (f"[-] WebSocket connection closed. Clients active: { len (request .app ['websockets' ])} " )
72198 return ws
73199
@@ -76,17 +202,24 @@ async def my_index_handler(request):
76202
77203async def start_background_tasks (app ):
78204 app ['websockets' ] = []
79- app ['redis_subscriber' ] = asyncio .create_task (redis_subscriber (app ['websockets' ]))
205+ args = app ['args' ]
206+ if args is not None and args .demo :
207+ app ['event_source' ] = asyncio .create_task (demo_publisher (app ['websockets' ], args ))
208+ else :
209+ app ['event_source' ] = asyncio .create_task (redis_subscriber (app ['websockets' ]))
80210
81211async def cleanup_background_tasks (app ):
82- app ['redis_subscriber' ].cancel ()
83- await app ['redis_subscriber' ]
212+ app ['event_source' ].cancel ()
213+ try :
214+ await app ['event_source' ]
215+ except asyncio .CancelledError :
216+ pass
84217
85218async def check_redis_connection ():
86219 """Check Redis connection on startup and wait until available."""
87220 print ("[*] Checking Redis connection..." )
88221 waiting_printed = False
89-
222+
90223 while True :
91224 try :
92225 r = redis .Redis .from_url (redis_url )
@@ -100,8 +233,9 @@ async def check_redis_connection():
100233 waiting_printed = True
101234 await asyncio .sleep (5 )
102235
103- async def make_webapp ():
104- app = web .Application ()
236+ async def make_webapp (args = None ):
237+ app = web .Application (middlewares = [security_headers ])
238+ app ['args' ] = args
105239 app .add_routes ([
106240 web .get ('/' , my_index_handler ),
107241 web .get ('/websocket' , my_websocket_handler ),
@@ -113,10 +247,30 @@ async def make_webapp():
113247 app .on_cleanup .append (cleanup_background_tasks )
114248 return app
115249
250+
251+ def parse_args (argv = None ):
252+ parser = argparse .ArgumentParser (description = version )
253+ parser .add_argument ('--host' , default = DEFAULT_WEB_HOST ,
254+ help = f'listen address (default: { DEFAULT_WEB_HOST } ; '
255+ 'containers pass --host 0.0.0.0 explicitly)' )
256+ parser .add_argument ('--port' , type = int , default = DEFAULT_WEB_PORT ,
257+ help = f'web server port (default: { DEFAULT_WEB_PORT } )' )
258+ parser .add_argument ('--redis-url' , default = DEFAULT_REDIS_URL ,
259+ help = f'Redis URL (default: { DEFAULT_REDIS_URL } )' )
260+ parser .add_argument ('--demo' , action = 'store_true' ,
261+ help = 'serve synthetic demo events — DEMO ONLY, never in production' )
262+ add_demo_arguments (parser )
263+ return parser .parse_args (argv )
264+
265+
116266if __name__ == '__main__' :
267+ cli_args = parse_args ()
268+ redis_url = cli_args .redis_url
117269 print (version )
118- # Check Redis connection on startup
119- asyncio .run (check_redis_connection ())
270+ if cli_args .demo :
271+ print ("[!] Demo mode requested — no Redis required." )
272+ else :
273+ # Check Redis connection on startup
274+ asyncio .run (check_redis_connection ())
120275 print ("[*] Starting web server...\n " )
121- web .run_app (make_webapp (), port = web_port )
122-
276+ web .run_app (make_webapp (cli_args ), host = cli_args .host , port = cli_args .port )
0 commit comments