Skip to content

24 - Browser Security

Overview

Tradeboard implements browser-side security measures including session management, CSRF protection, secure cookies, and content security policies.

Architecture Diagram

┌──────────────────────────────────────────────────────────────────────────────┐
│                       Browser Security Architecture                          │
└──────────────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────────────┐
│                               Security Layers                                │
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐     │
│  │  Layer 1: Session Security                                           │    │
│  │  - Session-based authentication                                      │    │
│  │  - Auto-expiry at 3 AM IST (configurable)                           │     │
│  │  - Token revocation on logout                                        │    │
│  └─────────────────────────────────────────────────────────────────────┘     │
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐     │
│  │  Layer 2: Cookie Security                                            │    │
│  │  - Secure flag (HTTPS only)                                          │    │
│  │  - HttpOnly flag (no JS access)                                      │    │
│  │  - SameSite=Lax (CSRF protection)                                    │    │
│  └─────────────────────────────────────────────────────────────────────┘     │
│                                                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐     │
│  │  Layer 3: Authentication Flow                                        │    │
│  │  - Argon2 password hashing                                           │    │
│  │  - TOTP support for 2FA                                              │    │
│  │  - Rate limiting on login                                            │    │
│  └─────────────────────────────────────────────────────────────────────┘     │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘

Session Management

Session Lifecycle

┌─────────────────────────────────────────────────────────────────┐
│                        Session Lifecycle                        │
│                                                                 │
│  Login → Create Session → Set Expiry → Validate on Request      │
│                                            │                    │
│              ┌─────────────────────────────┴───────┐            │
│              │                                     │            │
│           Valid                               Expired           │
│              │                                     │            │
│              ▼                                     ▼            │
│         Continue                            Redirect to         │
│         Request                             Login Page          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Session Expiry Configuration

bash
# .env
SESSION_EXPIRY_TIME = '03:00'      # daily expiry, IST (code default 03:00)
DISABLE_SESSION_EXPIRY = 'false'   # 'true' for 24/7 crypto deployments

is_session_valid() in utils/session.py expires a session when the current IST time is past today's boundary and the login happened before it. app.py also registers a check_session_expiry before_request guard that skips static assets, /assets/, /api/, and the public routes.

Session Validation

python
from utils.session import check_session_validity

@bp.route('/dashboard')
@check_session_validity
def dashboard():
    # Only accessible with valid session
    return render_template('dashboard.html')
python
# app.py
USE_HTTPS = HOST_SERVER.startswith("https://")
session_cookie_name = os.getenv("SESSION_COOKIE_NAME", "session")

app.config.update(
    SESSION_COOKIE_HTTPONLY=True,      # No JavaScript access
    SESSION_COOKIE_SAMESITE="Lax",     # CSRF protection
    SESSION_COOKIE_SECURE=USE_HTTPS,   # Secure only when HOST_SERVER is https
    SESSION_COOKIE_NAME=session_cookie_name,
)

if USE_HTTPS:
    app.config["SESSION_COOKIE_NAME"] = f"__Secure-{session_cookie_name}"

PERMANENT_SESSION_LIFETIME is not a fixed 24 hours: handle_auth_success() sets it per login from get_session_expiry_time(), so the cookie expires at the next SESSION_EXPIRY_TIME boundary. The CSRF cookie mirrors the same flags: WTF_CSRF_COOKIE_HTTPONLY, WTF_CSRF_COOKIE_SAMESITE="Lax", WTF_CSRF_COOKIE_SECURE=USE_HTTPS, name from CSRF_COOKIE_NAME (default csrf_token, prefixed __Secure- on HTTPS). CSRF protection itself is toggled by CSRF_ENABLED (default TRUE) and CSRF_TIME_LIMIT is unlimited unless set.

FlagPurpose
SecureOnly sent over HTTPS, and only enabled when HOST_SERVER is an https:// URL
HttpOnlyCannot be read by JavaScript
SameSite=LaxPrevents CSRF in most cases
__Secure- name prefixAdded automatically on HTTPS installs

Password Security

Argon2 Hashing

Hashing lives on the User model in database/user_db.py. The pepper is the full API_KEY_PEPPER value, not a slice of APP_KEY, and PasswordHasher() is used with its library defaults.

python
# database/user_db.py
ph = PasswordHasher()
PASSWORD_PEPPER = os.getenv("API_KEY_PEPPER")  # module refuses to import if unset or < 32 chars

class User(Base):
    def set_password(self, password):
        self.password_hash = ph.hash(password + PASSWORD_PEPPER)

    def check_password(self, password):
        try:
            ph.verify(self.password_hash, password + PASSWORD_PEPPER)
            if ph.check_needs_rehash(self.password_hash):
                self.set_password(password)
                db_session.commit()
            return True
        except VerifyMismatchError:
            return False

Password Requirements

python
# utils/auth_utils.py
def validate_password_strength(password):
    """Returns (is_valid, error_message or None)"""
    if not password:
        return False, "Password is required"
    if len(password) < 8:
        return False, "Password must be at least 8 characters long"
    if not re.search(r"[A-Z]", password):
        return False, "Password must contain at least 1 uppercase letter (A-Z)"
    if not re.search(r"[a-z]", password):
        return False, "Password must contain at least 1 lowercase letter (a-z)"
    if not re.search(r"[0-9]", password):
        return False, "Password must contain at least 1 number (0-9)"
    if not re.search(r"[!@#$%^&*]", password):
        return False, "Password must contain at least 1 special character (!@#$%^&*)"
    return True, None

The accepted special characters are exactly !@#$%^&*. On success the second element is None, not a message.

Login Rate Limiting

Configuration

bash
# .env
LOGIN_RATE_LIMIT_MIN = "5 per minute"    # code default: 5 per minute
LOGIN_RATE_LIMIT_HOUR = "25 per hour"    # code default: 25 per hour
RESET_RATE_LIMIT = "15 per hour"         # code default: 15 per hour

Implementation

python
# limiter.py
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    key_func=get_remote_address,
    storage_uri="memory://",
    strategy="moving-window",
)

# blueprints/auth.py
LOGIN_RATE_LIMIT_MIN = os.getenv("LOGIN_RATE_LIMIT_MIN", "5 per minute")
LOGIN_RATE_LIMIT_HOUR = os.getenv("LOGIN_RATE_LIMIT_HOUR", "25 per hour")

@auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit(LOGIN_RATE_LIMIT_MIN)
@limiter.limit(LOGIN_RATE_LIMIT_HOUR)
def login():
    ...

The limiter keys on get_remote_address, which is Werkzeug's request.remote_addr, not on the TRUST_PROXY_HEADERS-aware get_real_ip() helper used by the IP-ban and audit paths. POST /auth/login/totp and POST /auth/broker carry the same two login limits.

TOTP Two-Factor Authentication

Setup Flow

┌──────────────────────────────────────────────────────────────────┐
│                          2FA Setup Flow                          │
│                                                                  │
│  1. User enables 2FA in settings                                 │
│  2. Generate TOTP secret                                         │
│  3. Display QR code for authenticator app                        │
│  4. User enters code to verify                                   │
│  5. Store encrypted secret in database                           │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

TOTP Validation

python
# database/user_db.py
class User(Base):
    def get_totp_secret(self):
        """Decrypt the at-rest Fernet ciphertext. The only supported reader."""
        return safe_decrypt_token(self.totp_secret) or self.totp_secret

    def verify_totp(self, token):
        return pyotp.TOTP(self.get_totp_secret()).verify(token)

The totp_secret column holds Fernet ciphertext, so pyotp must be handed get_totp_secret() rather than the raw column. See 50-totp-configuration.md for the master switch and the three purpose flags.

Token Revocation

On Logout

python
# utils/session.py
def revoke_user_tokens(revoke_db_tokens=True):
    """Revoke auth tokens for the current user when the session expires.

    revoke_db_tokens=False clears local caches only, preserving the API key.
    """
    username = session.get("user")

    # 1. Drop the auth and feed token cache entries first
    # 2. Multi-device guard: if another device re-authenticated after today's
    #    rollover, drop only this device's active_sessions row and return,
    #    so the shared broker token and its WebSocket feed survive
    # 3. Publish ZeroMQ cache invalidation for the WebSocket proxy process
    # 4. Clear the symbol cache, revoke in the DB, broadcast force_logout

The teardown is deliberately conditional. A stale cookie hitting the daily rollover must not revoke a broker token that a second device has already refreshed, so _has_fresher_session() short-circuits the global teardown.

On Session Expiry

python
@check_session_validity
def protected_route():
    """Automatically revokes tokens if session expired"""
    pass

React Frontend Security

API Key Handling

typescript
// Never expose API key in browser
// Use session-based auth for web UI
// API keys only for external integrations

// Secure API call
const response = await fetch('/api/v1/positionbook', {
    method: 'POST',
    credentials: 'include',  // Send session cookie
    headers: {
        'Content-Type': 'application/json'
    }
});

AJAX Request Detection

python
# utils/auth_utils.py
def is_ajax_request():
    """Check if the current request is an AJAX/fetch request from React."""
    if request.headers.get("X-Requested-With") == "XMLHttpRequest":
        return True
    if "application/json" in request.headers.get("Accept", ""):
        return True
    if request.method == "POST" and "multipart/form-data" in request.headers.get("Content-Type", ""):
        # React form submissions use FormData
        return True
    return False

check_session_validity uses its own slightly wider test (it also accepts request.is_json and an application/json content type) to decide between a 401 JSON body and a redirect to the login page.

Security Headers

csp.py installs a single after_request hook that applies both the CSP header and the fixed header set.

python
# csp.py, get_security_headers()
headers["X-Frame-Options"] = "DENY"                # hard-coded, not SAMEORIGIN
headers["X-Content-Type-Options"] = "nosniff"      # hard-coded
headers["X-XSS-Protection"] = "1; mode=block"      # hard-coded
headers["Referrer-Policy"] = os.getenv("REFERRER_POLICY", "strict-origin-when-cross-origin")
headers["Permissions-Policy"] = os.getenv(
    "PERMISSIONS_POLICY",
    "camera=(), microphone=(), geolocation=(), payment=(), usb=(), "
    "screen-wake-lock=(), web-share=()",
)

REFERRER_POLICY and PERMISSIONS_POLICY are read from the environment but are not listed in .sample.env, so most installs run on the code defaults above.

Content Security Policy

CSP_ENABLED defaults to TRUE. When CSP_REPORT_ONLY=TRUE the policy is emitted as Content-Security-Policy-Report-Only instead. A CSP already set by the route handler is left alone, which is what lets the Remote MCP /oauth/authorize consent page widen form-action to the registered redirect origin.

Several code defaults are narrower than the values shipped in .sample.env, so an install that copies the sample gets the wider policy.

DirectiveCode default (csp.py).sample.env value
default-src'self'same
script-src'self' https://cdn.socket.io'self' 'unsafe-inline' https://cdn.socket.io https://static.cloudflareinsights.com
style-src'self' 'unsafe-inline'same
img-src'self' data: blob:'self' data:
connect-src'self' wss: ws:'self' wss: ws: https://cdn.socket.io
font-src'self'same
object-src'none'same
media-src'self''self' data: https://*.amazonaws.com https://*.cloudfront.net
frame-src'self'same
form-action'self'same
base-uri'self'same
frame-ancestors'self'same

child-src, report-uri, and report-to have no code default and are emitted only when the matching env var is set. upgrade-insecure-requests is added only when CSP_UPGRADE_INSECURE_REQUESTS=TRUE, which defaults to FALSE in both code and .sample.env.

CORS

cors.py applies Flask-CORS to r"/api/*" only.

SettingCode default (cors.py).sample.env value
CORS_ENABLEDFALSETRUE
CORS_ALLOWED_ORIGINSunset, so no origins keyhttp://127.0.0.1:5000
CORS_ALLOWED_METHODSunsetGET,POST,DELETE,PUT,PATCH
CORS_ALLOWED_HEADERSunsetContent-Type,Authorization,X-Requested-With
CORS_EXPOSED_HEADERSunsetempty
CORS_ALLOW_CREDENTIALSFALSEFALSE
CORS_MAX_AGEunset86400

When CORS_ENABLED is not TRUE, get_cors_config() returns an empty dict and Flask-CORS falls back to its own defaults for /api/*. Every other key is applied only when the env var is present, so an unset variable means "leave it to Flask-CORS", not "deny".

Session Storage

What's Stored

The Flask session is a signed, unencrypted client-side cookie, so everything below is readable by whoever holds the cookie.

python
session["user"] = username            # set by the password step in blueprints/auth.py
session["logged_in"] = True           # set by handle_auth_success() after broker auth
session["broker"] = broker
session["user_session_key"] = user_session_key
session["session_id"] = secrets.token_hex(32)   # matches the active_sessions row
session["login_time"] = now_ist.isoformat()     # IST ISO string, not a datetime
session["FEED_TOKEN"] = feed_token     # only when the broker returns one
session["USER_ID"] = user_id           # only when the broker returns one
session["totp_verified_at"] = ...      # ISO timestamp, freshness for OAuth consent
session["last_heartbeat"] = ...        # throttles the active-session last_seen write

What's NOT Stored

  • Passwords (only Argon2 hashes in the DB)
  • API keys (hashed and encrypted in the DB)
  • The broker auth token. handle_auth_success() carries an explicit note that it must never go in the session cookie; get_auth_token() reads the encrypted DB copy instead
  • Raw password-reset tokens. Only their SHA-256 hash is stored in the session

Credential Masking

Display Masking

python
# utils/auth_utils.py
def mask_api_credential(credential, show_chars=4):
    """Mask credentials for display, returning a fixed-length output."""
    if not credential:
        return ""
    if len(credential) <= show_chars:
        return "*" * 8
    return credential[:show_chars] + "*" * 8

# Example: "abc123def456" -> "abc1********"

The suffix is always eight asterisks and no trailing characters are revealed. The fixed length is intentional: it hides the secret's true length, so a screenshot cannot betray which broker a key belongs to, and it bounds the rendered column width. blueprints/broker_credentials.mask_secret mirrors this.

Key Files Reference

FilePurpose
utils/session.pySession validity, daily expiry, token revocation
utils/auth_utils.pyPassword strength, credential masking, AJAX detection, login success handling
database/user_db.pyUser model, Argon2 hashing, TOTP
blueprints/auth.pyAuth routes
csp.pyCSP and the fixed security headers
cors.pyCORS configuration for /api/*
app.pyCookie and CSRF configuration, session-expiry guard
frontend/src/api/Secure API calls