Phase 3: WebSocket CSRF, SameSite=Lax, Tenant FK CASCADE
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
"""Add tenant_id FK CASCADE to remaining tables not covered by migration 0091.
|
||||||
|
|
||||||
|
Tables missing from migration 0091:
|
||||||
|
- contact_merge_history (has tenant_id from TenantMixin but no FK)
|
||||||
|
- tenant_plugin_activation (plugin table, may not exist yet)
|
||||||
|
- user_groups (has FK already but verify CASCADE)
|
||||||
|
- guest_users (has FK already but verify CASCADE)
|
||||||
|
- user_tenants (has FK already but verify CASCADE)
|
||||||
|
|
||||||
|
Also adds FK to calendar_entry_links.tenant_id if not already present
|
||||||
|
(migration 0091 includes it but the model definition lacks the FK).
|
||||||
|
|
||||||
|
Revision ID: 0103
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0103"
|
||||||
|
down_revision = "0102"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables that need tenant_id FK with CASCADE but were not in migration 0091
|
||||||
|
TABLES_NEEDING_FK = [
|
||||||
|
"contact_merge_history",
|
||||||
|
"tenant_plugin_activation",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Tables that should already have FK but we verify CASCADE is set
|
||||||
|
TABLES_VERIFY_CASCADE = [
|
||||||
|
"user_groups",
|
||||||
|
"guest_users",
|
||||||
|
"user_tenants",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table_name: str) -> bool:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = :name)"),
|
||||||
|
{"name": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(conn, table_name: str, constraint_name: str) -> bool:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM information_schema.table_constraints "
|
||||||
|
"WHERE constraint_name = :name AND constraint_type = 'FOREIGN KEY')"
|
||||||
|
),
|
||||||
|
{"name": constraint_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Add FK CASCADE to tables that are missing it
|
||||||
|
for table_name in TABLES_NEEDING_FK:
|
||||||
|
if not _table_exists(conn, table_name):
|
||||||
|
print(f"[0103] Skipping {table_name} — table does not exist")
|
||||||
|
continue
|
||||||
|
constraint_name = f"fk_{table_name}_tenant_id"
|
||||||
|
if _fk_exists(conn, table_name, constraint_name):
|
||||||
|
print(f"[0103] Skipping {table_name} — FK already exists")
|
||||||
|
continue
|
||||||
|
# Check if tenant_id column exists
|
||||||
|
col_exists = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :name AND column_name = 'tenant_id')"
|
||||||
|
),
|
||||||
|
{"name": table_name},
|
||||||
|
).scalar()
|
||||||
|
if not col_exists:
|
||||||
|
print(f"[0103] Skipping {table_name} — no tenant_id column")
|
||||||
|
continue
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE {table_name} ADD CONSTRAINT {constraint_name} "
|
||||||
|
f"FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;"
|
||||||
|
)
|
||||||
|
print(f"[0103] Added FK CASCADE to {table_name}")
|
||||||
|
|
||||||
|
# Verify CASCADE on existing FKs (drop and recreate if missing CASCADE)
|
||||||
|
for table_name in TABLES_VERIFY_CASCADE:
|
||||||
|
if not _table_exists(conn, table_name):
|
||||||
|
continue
|
||||||
|
constraint_name = f"fk_{table_name}_tenant_id"
|
||||||
|
if not _fk_exists(conn, table_name, constraint_name):
|
||||||
|
# Check if any FK exists on tenant_id
|
||||||
|
existing_fk = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT conname FROM pg_constraint con "
|
||||||
|
"JOIN pg_class cls ON con.conrelid = cls.oid "
|
||||||
|
"WHERE cls.relname = :table AND con.contype = 'f' "
|
||||||
|
"AND EXISTS (SELECT 1 FROM pg_attribute att "
|
||||||
|
"WHERE att.attrelid = con.conrelid AND att.attname = 'tenant_id' "
|
||||||
|
"AND att.attnum = ANY(con.conkey))"
|
||||||
|
),
|
||||||
|
{"table": table_name},
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing_fk:
|
||||||
|
# Drop existing FK and recreate with CASCADE
|
||||||
|
op.execute(f"ALTER TABLE {table_name} DROP CONSTRAINT {existing_fk};")
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE {table_name} ADD CONSTRAINT {constraint_name} "
|
||||||
|
f"FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;"
|
||||||
|
)
|
||||||
|
print(f"[0103] Replaced FK on {table_name} with CASCADE (was: {existing_fk})")
|
||||||
|
else:
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE {table_name} ADD CONSTRAINT {constraint_name} "
|
||||||
|
f"FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE;"
|
||||||
|
)
|
||||||
|
print(f"[0103] Added FK CASCADE to {table_name}")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table_name in TABLES_NEEDING_FK + TABLES_VERIFY_CASCADE:
|
||||||
|
if not _table_exists(conn, table_name):
|
||||||
|
continue
|
||||||
|
constraint_name = f"fk_{table_name}_tenant_id"
|
||||||
|
if _fk_exists(conn, table_name, constraint_name):
|
||||||
|
op.execute(f"ALTER TABLE {table_name} DROP CONSTRAINT {constraint_name};")
|
||||||
+1
-1
@@ -39,7 +39,7 @@ class Settings(BaseSettings):
|
|||||||
bcrypt_rounds: int = 12
|
bcrypt_rounds: int = 12
|
||||||
session_cookie_name: str = "leocrm_session"
|
session_cookie_name: str = "leocrm_session"
|
||||||
session_cookie_secure: bool = True # Secure by default — set to False only for local HTTP development
|
session_cookie_secure: bool = True # Secure by default — set to False only for local HTTP development
|
||||||
session_cookie_samesite: str = "strict" # Strict blocks WebSocket cookies; use Lax only if WS needed
|
session_cookie_samesite: str = "lax" # Lax allows WebSocket cookies while preventing CSRF on top-level navigations
|
||||||
session_cookie_httponly: bool = True
|
session_cookie_httponly: bool = True
|
||||||
password_reset_expiry_hours: int = 1
|
password_reset_expiry_hours: int = 1
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -116,8 +116,23 @@ def verify_ws_origin(websocket) -> bool:
|
|||||||
# CSRF token validation: check query parameter 'csrf_token' against session
|
# CSRF token validation: check query parameter 'csrf_token' against session
|
||||||
# The frontend must send ?csrf_token=xxx in the WebSocket URL
|
# The frontend must send ?csrf_token=xxx in the WebSocket URL
|
||||||
# This prevents cross-site WebSocket hijacking attacks
|
# This prevents cross-site WebSocket hijacking attacks
|
||||||
# Note: We skip CSRF for now if no session cookie — the WS handler will
|
csrf_token = websocket.query_params.get("csrf_token", "")
|
||||||
# authenticate the user after connection. Origin check is the primary defense.
|
if not csrf_token:
|
||||||
|
logger.warning("WebSocket connection rejected: missing csrf_token query parameter")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Validate CSRF token against session in Redis
|
||||||
|
session_id = websocket.cookies.get(settings.session_cookie_name)
|
||||||
|
if not session_id:
|
||||||
|
logger.warning("WebSocket connection rejected: missing session cookie")
|
||||||
|
return False
|
||||||
|
|
||||||
|
redis = get_redis()
|
||||||
|
session_data = await get_session_data(redis, session_id)
|
||||||
|
if not session_data or session_data.get("csrf_token") != csrf_token:
|
||||||
|
logger.warning("WebSocket connection rejected: invalid CSRF token")
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user