security: purge leaked deploy files, cookie auth, sanitize blog

This commit is contained in:
yves.gugger
2025-12-12 09:28:28 +01:00
parent ffcd47e61d
commit 5d7bd1a04f
15 changed files with 488 additions and 112 deletions

9
.gitignore vendored
View File

@ -28,6 +28,15 @@ dist/
.env.*.local .env.*.local
*.log *.log
# Deployment env files (MUST NOT be committed)
DEPLOY_*.env
# Sensitive runtime artifacts
backend/data/cookies/*.json
# Local security backup artifacts (created during history rewrite)
.security-backup/
# IDEs # IDEs
.vscode/ .vscode/
.idea/ .idea/

View File

@ -0,0 +1,55 @@
# Deployment environment template (NO SECRETS)
#
# Copy to a *local-only* file and keep it OUT of git:
# cp DEPLOY_backend.env.example DEPLOY_backend.env
#
# Then fill values from your password manager / secret store.
# Never commit DEPLOY_backend.env.
#
# Core
DATABASE_URL=postgresql+asyncpg://pounce:<DB_PASSWORD>@db:5432/pounce
SECRET_KEY=<GENERATE_64_HEX_CHARS>
ACCESS_TOKEN_EXPIRE_MINUTES=1440
DEBUG=false
ENVIRONMENT=production
SITE_URL=https://your-domain.com
# CORS (comma-separated)
ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com
# Email (optional)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=pounce
SMTP_USE_TLS=true
SMTP_USE_SSL=false
CONTACT_EMAIL=
# Stripe (optional)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_PRICE_TRADER=
STRIPE_PRICE_TYCOON=
STRIPE_WEBHOOK_SECRET=
# OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://api.your-domain.com/api/v1/oauth/google/callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=https://api.your-domain.com/api/v1/oauth/github/callback
# Optional integrations
DROPCATCH_CLIENT_ID=
DROPCATCH_CLIENT_SECRET=
DROPCATCH_API_BASE=https://api.dropcatch.com
SEDO_PARTNER_ID=
SEDO_SIGN_KEY=
SEDO_API_BASE=https://api.sedo.com/api/v1/
MOZ_ACCESS_ID=
MOZ_SECRET_KEY=

View File

@ -0,0 +1,7 @@
# Deployment environment template (NO SECRETS)
#
# Copy to a *local-only* file and keep it OUT of git:
# cp DEPLOY_frontend.env.example DEPLOY_frontend.env
#
NEXT_PUBLIC_API_URL=https://your-domain.com/api

View File

@ -17,7 +17,7 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request, Response
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlalchemy import select from sqlalchemy import select
from slowapi import Limiter from slowapi import Limiter
@ -25,10 +25,11 @@ from slowapi.util import get_remote_address
from app.api.deps import Database, CurrentUser from app.api.deps import Database, CurrentUser
from app.config import get_settings from app.config import get_settings
from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token from app.schemas.auth import UserCreate, UserLogin, UserResponse, LoginResponse
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.email_service import email_service from app.services.email_service import email_service
from app.models.user import User from app.models.user import User
from app.security import set_auth_cookie, clear_auth_cookie
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -146,8 +147,8 @@ async def register(
return user return user
@router.post("/login", response_model=Token) @router.post("/login", response_model=LoginResponse)
async def login(user_data: UserLogin, db: Database): async def login(user_data: UserLogin, db: Database, response: Response):
""" """
Authenticate user and return JWT token. Authenticate user and return JWT token.
@ -210,13 +211,24 @@ async def login(user_data: UserLogin, db: Database):
data={"sub": str(user.id), "email": user.email}, data={"sub": str(user.id), "email": user.email},
expires_delta=access_token_expires, expires_delta=access_token_expires,
) )
return Token( # Set HttpOnly cookie (preferred for browser clients)
access_token=access_token, set_auth_cookie(
token_type="bearer", response=response,
expires_in=settings.access_token_expire_minutes * 60, token=access_token,
max_age_seconds=settings.access_token_expire_minutes * 60,
) )
# Do NOT return the token in the response body (prevents leaks via logs/JS storage)
return LoginResponse(expires_in=settings.access_token_expire_minutes * 60)
@router.post("/logout", response_model=MessageResponse)
async def logout(response: Response):
"""Clear auth cookie."""
clear_auth_cookie(response)
return MessageResponse(message="Logged out")
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: CurrentUser): async def get_current_user_info(current_user: CurrentUser):

View File

@ -15,6 +15,7 @@ from sqlalchemy.orm import selectinload
from app.api.deps import Database, get_current_user, get_current_user_optional from app.api.deps import Database, get_current_user, get_current_user_optional
from app.models.user import User from app.models.user import User
from app.models.blog import BlogPost from app.models.blog import BlogPost
from app.services.html_sanitizer import sanitize_html
router = APIRouter() router = APIRouter()
@ -194,7 +195,9 @@ async def get_blog_post(
post.view_count += 1 post.view_count += 1
await db.commit() await db.commit()
return post.to_dict(include_content=True) data = post.to_dict(include_content=True)
data["content"] = sanitize_html(data.get("content") or "")
return data
# ============== Admin Endpoints ============== # ============== Admin Endpoints ==============
@ -255,7 +258,7 @@ async def create_blog_post(
post = BlogPost( post = BlogPost(
title=data.title, title=data.title,
slug=slug, slug=slug,
content=data.content, content=sanitize_html(data.content),
excerpt=data.excerpt, excerpt=data.excerpt,
cover_image=data.cover_image, cover_image=data.cover_image,
category=data.category, category=data.category,
@ -322,7 +325,7 @@ async def update_blog_post(
# Optionally update slug if title changes # Optionally update slug if title changes
# post.slug = generate_slug(data.title) # post.slug = generate_slug(data.title)
if data.content is not None: if data.content is not None:
post.content = data.content post.content = sanitize_html(data.content)
if data.excerpt is not None: if data.excerpt is not None:
post.excerpt = data.excerpt post.excerpt = data.excerpt
if data.cover_image is not None: if data.cover_image is not None:

View File

@ -1,21 +1,22 @@
"""API dependencies.""" """API dependencies."""
from typing import Annotated, Optional from typing import Annotated, Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.services.auth import AuthService from app.services.auth import AuthService
from app.models.user import User from app.models.user import User
from app.security import AUTH_COOKIE_NAME
# Security scheme # Security scheme
security = HTTPBearer()
security_optional = HTTPBearer(auto_error=False) security_optional = HTTPBearer(auto_error=False)
async def get_current_user( async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], request: Request,
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security_optional)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
) -> User: ) -> User:
"""Get current authenticated user from JWT token.""" """Get current authenticated user from JWT token."""
@ -25,7 +26,15 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
token = credentials.credentials token: Optional[str] = None
if credentials is not None:
token = credentials.credentials
if not token:
token = request.cookies.get(AUTH_COOKIE_NAME)
if not token:
raise credentials_exception
payload = AuthService.decode_token(token) payload = AuthService.decode_token(token)
if payload is None: if payload is None:
@ -67,6 +76,7 @@ async def get_current_active_user(
async def get_current_user_optional( async def get_current_user_optional(
request: Request,
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security_optional)], credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security_optional)],
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
) -> Optional[User]: ) -> Optional[User]:
@ -75,10 +85,15 @@ async def get_current_user_optional(
This allows endpoints to work for both authenticated and anonymous users, This allows endpoints to work for both authenticated and anonymous users,
potentially showing different content based on auth status. potentially showing different content based on auth status.
""" """
if credentials is None: token: Optional[str] = None
if credentials is not None:
token = credentials.credentials
if not token:
token = request.cookies.get(AUTH_COOKIE_NAME)
if not token:
return None return None
token = credentials.credentials
payload = AuthService.decode_token(token) payload = AuthService.decode_token(token)
if payload is None: if payload is None:

View File

@ -5,15 +5,20 @@ Supports:
- Google OAuth 2.0 - Google OAuth 2.0
- GitHub OAuth - GitHub OAuth
""" """
import base64
import hashlib
import hmac
import json
import os import os
import secrets import secrets
import logging import logging
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
from fastapi import APIRouter, HTTPException, status, Query from fastapi import APIRouter, HTTPException, status, Query, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@ -23,6 +28,7 @@ from app.config import get_settings
from app.models.user import User from app.models.user import User
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
from app.services.auth import AuthService from app.services.auth import AuthService
from app.security import set_auth_cookie, should_use_secure_cookies
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -41,6 +47,123 @@ GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:8000/ap
FRONTEND_URL = os.getenv("SITE_URL", "http://localhost:3000") FRONTEND_URL = os.getenv("SITE_URL", "http://localhost:3000")
OAUTH_STATE_TTL_SECONDS = 600 # 10 minutes
def _sanitize_redirect_path(redirect: Optional[str]) -> str:
"""
Only allow internal (relative) redirects.
Prevents open-redirect and token/referrer exfil paths.
"""
default = "/terminal/radar"
if not redirect:
return default
r = redirect.strip()
if not r.startswith("/"):
return default
if r.startswith("//"):
return default
if "://" in r:
return default
if "\\" in r:
return default
if len(r) > 2048:
return default
return r
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
pad = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + pad)
def _oauth_nonce_cookie_name(provider: str) -> str:
return f"pounce_oauth_nonce_{provider}"
def _set_oauth_nonce_cookie(response: RedirectResponse, provider: str, nonce: str) -> None:
response.set_cookie(
key=_oauth_nonce_cookie_name(provider),
value=nonce,
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=OAUTH_STATE_TTL_SECONDS,
path="/api/v1/oauth",
)
def _clear_oauth_nonce_cookie(response: RedirectResponse, provider: str) -> None:
response.delete_cookie(
key=_oauth_nonce_cookie_name(provider),
path="/api/v1/oauth",
)
def _create_oauth_state(provider: str, nonce: str, redirect_path: str) -> str:
"""
Signed, short-lived state payload.
Also protects the redirect_path against tampering.
"""
if not settings.secret_key:
raise RuntimeError("SECRET_KEY is required for OAuth state signing")
payload = {
"p": provider,
"n": nonce,
"r": redirect_path,
"ts": int(time.time()),
}
payload_b64 = _b64url_encode(
json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
)
sig = hmac.new(
settings.secret_key.encode("utf-8"),
payload_b64.encode("utf-8"),
hashlib.sha256,
).digest()
return f"{payload_b64}.{_b64url_encode(sig)}"
def _verify_oauth_state(state: str, provider: str) -> tuple[str, str]:
if not settings.secret_key:
raise ValueError("OAuth state verification not available (missing SECRET_KEY)")
if not state or "." not in state:
raise ValueError("Invalid state format")
payload_b64, sig_b64 = state.split(".", 1)
expected_sig = _b64url_encode(
hmac.new(
settings.secret_key.encode("utf-8"),
payload_b64.encode("utf-8"),
hashlib.sha256,
).digest()
)
if not hmac.compare_digest(expected_sig, sig_b64):
raise ValueError("Invalid state signature")
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
if payload.get("p") != provider:
raise ValueError("State provider mismatch")
ts = int(payload.get("ts") or 0)
if ts <= 0 or (int(time.time()) - ts) > OAUTH_STATE_TTL_SECONDS:
raise ValueError("State expired")
nonce = str(payload.get("n") or "")
redirect_path = _sanitize_redirect_path(payload.get("r"))
if not nonce:
raise ValueError("Missing nonce")
return nonce, redirect_path
# ============== Schemas ============== # ============== Schemas ==============
@ -102,7 +225,8 @@ async def get_or_create_oauth_user(
# Create new user # Create new user
user = User( user = User(
email=email.lower(), email=email.lower(),
hashed_password=secrets.token_urlsafe(32), # Random password (won't be used) # Random password (won't be used), but keep it a valid bcrypt hash.
hashed_password=AuthService.hash_password(secrets.token_urlsafe(32)),
name=name, name=name,
oauth_provider=provider, oauth_provider=provider,
oauth_id=oauth_id, oauth_id=oauth_id,
@ -169,11 +293,10 @@ async def google_login(redirect: Optional[str] = Query(None)):
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google OAuth not configured", detail="Google OAuth not configured",
) )
# Store redirect URL in state redirect_path = _sanitize_redirect_path(redirect)
state = secrets.token_urlsafe(16) nonce = secrets.token_urlsafe(16)
if redirect: state = _create_oauth_state("google", nonce, redirect_path)
state = f"{state}:{redirect}"
params = { params = {
"client_id": GOOGLE_CLIENT_ID, "client_id": GOOGLE_CLIENT_ID,
@ -186,11 +309,14 @@ async def google_login(redirect: Optional[str] = Query(None)):
} }
url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}" url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
return RedirectResponse(url=url) response = RedirectResponse(url=url)
_set_oauth_nonce_cookie(response, "google", nonce)
return response
@router.get("/google/callback") @router.get("/google/callback")
async def google_callback( async def google_callback(
request: Request,
code: str = Query(...), code: str = Query(...),
state: str = Query(""), state: str = Query(""),
db: Database = None, db: Database = None,
@ -202,10 +328,16 @@ async def google_callback(
detail="Google OAuth not configured", detail="Google OAuth not configured",
) )
# Parse redirect from state try:
redirect_path = "/command/dashboard" nonce, redirect_path = _verify_oauth_state(state, "google")
if ":" in state: except Exception as e:
_, redirect_path = state.split(":", 1) logger.warning(f"Invalid OAuth state (google): {e}")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
cookie_nonce = request.cookies.get(_oauth_nonce_cookie_name("google"))
if not cookie_nonce or not hmac.compare_digest(cookie_nonce, nonce):
logger.warning("OAuth nonce mismatch (google)")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
try: try:
# Exchange code for tokens # Exchange code for tokens
@ -257,12 +389,20 @@ async def google_callback(
# Create JWT # Create JWT
jwt_token, _ = create_jwt_for_user(user) jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token # Redirect to frontend WITHOUT token in URL; set auth cookie instead.
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}" query = {"redirect": redirect_path}
if is_new: if is_new:
redirect_url += "&new=true" query["new"] = "true"
redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}"
return RedirectResponse(url=redirect_url)
response = RedirectResponse(url=redirect_url)
_clear_oauth_nonce_cookie(response, "google")
set_auth_cookie(
response=response,
token=jwt_token,
max_age_seconds=settings.access_token_expire_minutes * 60,
)
return response
except Exception as e: except Exception as e:
logger.exception(f"Google OAuth error: {e}") logger.exception(f"Google OAuth error: {e}")
@ -281,11 +421,10 @@ async def github_login(redirect: Optional[str] = Query(None)):
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GitHub OAuth not configured", detail="GitHub OAuth not configured",
) )
# Store redirect URL in state redirect_path = _sanitize_redirect_path(redirect)
state = secrets.token_urlsafe(16) nonce = secrets.token_urlsafe(16)
if redirect: state = _create_oauth_state("github", nonce, redirect_path)
state = f"{state}:{redirect}"
params = { params = {
"client_id": GITHUB_CLIENT_ID, "client_id": GITHUB_CLIENT_ID,
@ -295,11 +434,14 @@ async def github_login(redirect: Optional[str] = Query(None)):
} }
url = f"https://github.com/login/oauth/authorize?{urlencode(params)}" url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
return RedirectResponse(url=url) response = RedirectResponse(url=url)
_set_oauth_nonce_cookie(response, "github", nonce)
return response
@router.get("/github/callback") @router.get("/github/callback")
async def github_callback( async def github_callback(
request: Request,
code: str = Query(...), code: str = Query(...),
state: str = Query(""), state: str = Query(""),
db: Database = None, db: Database = None,
@ -311,10 +453,16 @@ async def github_callback(
detail="GitHub OAuth not configured", detail="GitHub OAuth not configured",
) )
# Parse redirect from state try:
redirect_path = "/command/dashboard" nonce, redirect_path = _verify_oauth_state(state, "github")
if ":" in state: except Exception as e:
_, redirect_path = state.split(":", 1) logger.warning(f"Invalid OAuth state (github): {e}")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
cookie_nonce = request.cookies.get(_oauth_nonce_cookie_name("github"))
if not cookie_nonce or not hmac.compare_digest(cookie_nonce, nonce):
logger.warning("OAuth nonce mismatch (github)")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -399,12 +547,19 @@ async def github_callback(
# Create JWT # Create JWT
jwt_token, _ = create_jwt_for_user(user) jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token query = {"redirect": redirect_path}
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}"
if is_new: if is_new:
redirect_url += "&new=true" query["new"] = "true"
redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}"
return RedirectResponse(url=redirect_url)
response = RedirectResponse(url=redirect_url)
_clear_oauth_nonce_cookie(response, "github")
set_auth_cookie(
response=response,
token=jwt_token,
max_age_seconds=settings.access_token_expire_minutes * 60,
)
return response
except Exception as e: except Exception as e:
logger.exception(f"GitHub OAuth error: {e}") logger.exception(f"GitHub OAuth error: {e}")

View File

@ -39,6 +39,11 @@ class Token(BaseModel):
expires_in: int expires_in: int
class LoginResponse(BaseModel):
"""Login response when using HttpOnly cookie authentication."""
expires_in: int
class TokenData(BaseModel): class TokenData(BaseModel):
"""Schema for token payload data.""" """Schema for token payload data."""
user_id: Optional[int] = None user_id: Optional[int] = None

64
backend/app/security.py Normal file
View File

@ -0,0 +1,64 @@
"""
Security helpers (cookies, environment checks).
We use HttpOnly cookies for browser auth to avoid storing JWTs in localStorage/URLs.
"""
from __future__ import annotations
import os
from fastapi import Response
AUTH_COOKIE_NAME = "pounce_access_token"
def cookie_domain() -> str | None:
"""
Optional cookie domain override.
Use with care. Example (share across subdomains): COOKIE_DOMAIN=.pounce.ch
Leave empty in local development (localhost).
"""
value = os.getenv("COOKIE_DOMAIN", "").strip()
return value or None
def should_use_secure_cookies() -> bool:
"""
Determine whether cookies should be marked Secure.
Prefer explicit config via COOKIE_SECURE=true. Otherwise infer from SITE_URL / ENVIRONMENT.
"""
if os.getenv("COOKIE_SECURE", "").lower() == "true":
return True
site_url = os.getenv("SITE_URL", "")
if site_url.startswith("https://"):
return True
env = os.getenv("ENVIRONMENT", "").lower()
return env in {"prod", "production"}
def set_auth_cookie(response: Response, token: str, max_age_seconds: int) -> None:
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=max_age_seconds,
path="/",
domain=cookie_domain(),
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(
key=AUTH_COOKIE_NAME,
path="/",
domain=cookie_domain(),
)

View File

@ -0,0 +1,51 @@
"""
HTML sanitization utilities.
Goal: prevent XSS when rendering stored HTML (e.g. blog posts) via dangerouslySetInnerHTML.
"""
from __future__ import annotations
import bleach
_ALLOWED_TAGS = [
"p",
"br",
"hr",
"blockquote",
"pre",
"code",
"strong",
"em",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"a",
]
_ALLOWED_ATTRIBUTES = {
"a": ["href", "title", "rel", "target"],
}
_ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
def sanitize_html(html: str) -> str:
"""Sanitize potentially unsafe HTML input."""
if not html:
return ""
return bleach.clean(
html,
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRIBUTES,
protocols=_ALLOWED_PROTOCOLS,
strip=True,
)

View File

@ -36,6 +36,9 @@ apscheduler>=3.10.4
aiosmtplib>=3.0.2 aiosmtplib>=3.0.2
jinja2>=3.1.2 jinja2>=3.1.2
# HTML sanitization (XSS protection)
bleach>=6.1.0
# Payments # Payments
stripe>=7.0.0 stripe>=7.0.0

View File

@ -54,15 +54,27 @@ function LoginForm() {
const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false }) const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false })
const [verified, setVerified] = useState(false) const [verified, setVerified] = useState(false)
const sanitizeRedirect = (value: string | null | undefined): string => {
const fallback = '/terminal/radar'
if (!value) return fallback
const v = value.trim()
if (!v.startsWith('/')) return fallback
if (v.startsWith('//')) return fallback
if (v.includes('://')) return fallback
if (v.includes('\\')) return fallback
if (v.length > 2048) return fallback
return v
}
// Get redirect URL from query params or localStorage (set during registration) // Get redirect URL from query params or localStorage (set during registration)
const paramRedirect = searchParams.get('redirect') const paramRedirect = searchParams.get('redirect')
const [redirectTo, setRedirectTo] = useState(paramRedirect || '/terminal/radar') const [redirectTo, setRedirectTo] = useState(sanitizeRedirect(paramRedirect))
// Check localStorage for redirect (set during registration before email verification) // Check localStorage for redirect (set during registration before email verification)
useEffect(() => { useEffect(() => {
const storedRedirect = localStorage.getItem('pounce_redirect_after_login') const storedRedirect = localStorage.getItem('pounce_redirect_after_login')
if (storedRedirect && !paramRedirect) { if (storedRedirect && !paramRedirect) {
setRedirectTo(storedRedirect) setRedirectTo(sanitizeRedirect(storedRedirect))
} }
}, [paramRedirect]) }, [paramRedirect])
@ -101,7 +113,7 @@ function LoginForm() {
localStorage.removeItem('pounce_redirect_after_login') localStorage.removeItem('pounce_redirect_after_login')
// Redirect to intended destination or dashboard // Redirect to intended destination or dashboard
router.push(redirectTo) router.push(sanitizeRedirect(redirectTo))
} catch (err: unknown) { } catch (err: unknown) {
console.error('Login error:', err) console.error('Login error:', err)
if (err instanceof Error) { if (err instanceof Error) {

View File

@ -3,7 +3,7 @@
import { useEffect, Suspense } from 'react' import { useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { Loader2, CheckCircle } from 'lucide-react' import { Loader2 } from 'lucide-react'
function OAuthCallbackContent() { function OAuthCallbackContent() {
const router = useRouter() const router = useRouter()
@ -11,32 +11,40 @@ function OAuthCallbackContent() {
const { checkAuth } = useStore() const { checkAuth } = useStore()
useEffect(() => { useEffect(() => {
const token = searchParams.get('token') const sanitizeRedirect = (value: string | null): string => {
const redirect = searchParams.get('redirect') || '/terminal/radar' const fallback = '/terminal/radar'
if (!value) return fallback
const v = value.trim()
if (!v.startsWith('/')) return fallback
if (v.startsWith('//')) return fallback
if (v.includes('://')) return fallback
if (v.includes('\\')) return fallback
return v
}
const addWelcomeFlag = (path: string): string => {
try {
const u = new URL(path, window.location.origin)
u.searchParams.set('welcome', 'true')
return `${u.pathname}${u.search}${u.hash}`
} catch {
return path
}
}
const redirect = sanitizeRedirect(searchParams.get('redirect'))
const isNew = searchParams.get('new') === 'true' const isNew = searchParams.get('new') === 'true'
const error = searchParams.get('error') const error = searchParams.get('error')
if (error) { if (error) {
router.push(`/login?error=${error}`) router.push(`/login?error=${encodeURIComponent(error)}`)
return return
} }
if (token) { // Auth cookie is set by backend during OAuth callback; just refresh auth state.
// Store the token (using 'token' key to match api.ts) checkAuth().finally(() => {
localStorage.setItem('token', token) router.push(isNew ? addWelcomeFlag(redirect) : redirect)
})
// Update auth state
checkAuth().then(() => {
// Redirect with welcome message for new users
if (isNew) {
router.push(`${redirect}?welcome=true`)
} else {
router.push(redirect)
}
})
} else {
router.push('/login?error=no_token')
}
}, [searchParams, router, checkAuth]) }, [searchParams, router, checkAuth])
return ( return (

View File

@ -45,29 +45,10 @@ interface ApiError {
} }
class ApiClient { class ApiClient {
private token: string | null = null
get baseUrl(): string { get baseUrl(): string {
return getApiBaseUrl().replace('/api/v1', '') return getApiBaseUrl().replace('/api/v1', '')
} }
setToken(token: string | null) {
this.token = token
if (token) {
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
}
getToken(): string | null {
if (typeof window === 'undefined') return null
if (!this.token) {
this.token = localStorage.getItem('token')
}
return this.token
}
async request<T>( async request<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {}
@ -78,14 +59,10 @@ class ApiClient {
...options.headers as Record<string, string>, ...options.headers as Record<string, string>,
} }
const token = this.getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers, headers,
credentials: 'include', // send HttpOnly auth cookie
}) })
if (!response.ok) { if (!response.ok) {
@ -112,19 +89,21 @@ class ApiClient {
} }
async login(email: string, password: string) { async login(email: string, password: string) {
const response = await this.request<{ access_token: string; expires_in: number }>( const response = await this.request<{ expires_in: number }>(
'/auth/login', '/auth/login',
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
} }
) )
this.setToken(response.access_token)
return response return response
} }
async logout() { async logout() {
this.setToken(null) // Clears auth cookie on the backend
return this.request<{ message: string; success: boolean }>('/auth/logout', {
method: 'POST',
})
} }
async getMe() { async getMe() {

View File

@ -124,18 +124,16 @@ export const useStore = create<AppState>((set, get) => ({
set({ isLoading: true }) set({ isLoading: true })
try { try {
if (api.getToken()) { // Cookie-based auth: if cookie is present and valid, /auth/me succeeds.
const user = await api.getMe() const user = await api.getMe()
set({ user, isAuthenticated: true }) set({ user, isAuthenticated: true })
// Fetch in parallel for speed // Fetch in parallel for speed
await Promise.all([ await Promise.all([
get().fetchDomains(), get().fetchDomains(),
get().fetchSubscription() get().fetchSubscription()
]) ])
}
} catch { } catch {
api.logout()
set({ user: null, isAuthenticated: false }) set({ user: null, isAuthenticated: false })
} finally { } finally {
set({ isLoading: false }) set({ isLoading: false })