Skip to content

Commit cc888e7

Browse files
authored
Prepare T-Pot Attack Map 4.0.0 Release
2 parents e798fcb + 5a0b375 commit cc888e7

895 files changed

Lines changed: 27595 additions & 3203 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ lib/
55
pyvenv.cfg
66
__pycache__/
77

8+
.venv/
9+
static/dist/*.pmtiles
10+
tools/e2e/node_modules/
11+
tools/styles/node_modules/
12+
.DS_Store

.node-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
24.20.0

AttackMapServer.py

Lines changed: 182 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,100 @@
55
Adjusted code for asyncio, aiohttp and redis (asynchronous support) by t3chn0m4g3
66
"""
77

8+
import argparse
89
import asyncio
910
import json
11+
import re
12+
import time
13+
from urllib.parse import urlparse
1014

1115
import redis.asyncio as redis
1216
from 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

24104
async 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+
60171
async 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

77203
async 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

81211
async 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

85218
async 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+
116266
if __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

Comments
 (0)