From 5d7bd1a04f3f93b828d1dab30557aef9ae7a6f1f Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Fri, 12 Dec 2025 09:28:28 +0100 Subject: [PATCH] security: purge leaked deploy files, cookie auth, sanitize blog --- .gitignore | 9 + DEPLOY_backend.env.example | 55 ++++++ DEPLOY_frontend.env.example | 7 + backend/app/api/auth.py | 30 +++- backend/app/api/blog.py | 9 +- backend/app/api/deps.py | 29 ++- backend/app/api/oauth.py | 219 +++++++++++++++++++---- backend/app/schemas/auth.py | 5 + backend/app/security.py | 64 +++++++ backend/app/services/html_sanitizer.py | 51 ++++++ backend/requirements.txt | 3 + frontend/src/app/login/page.tsx | 18 +- frontend/src/app/oauth/callback/page.tsx | 48 ++--- frontend/src/lib/api.ts | 33 +--- frontend/src/lib/store.ts | 20 +-- 15 files changed, 488 insertions(+), 112 deletions(-) create mode 100644 DEPLOY_backend.env.example create mode 100644 DEPLOY_frontend.env.example create mode 100644 backend/app/security.py create mode 100644 backend/app/services/html_sanitizer.py diff --git a/.gitignore b/.gitignore index 31eb042..d30ddaa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,15 @@ dist/ .env.*.local *.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 .vscode/ .idea/ diff --git a/DEPLOY_backend.env.example b/DEPLOY_backend.env.example new file mode 100644 index 0000000..aab70bc --- /dev/null +++ b/DEPLOY_backend.env.example @@ -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:5432/pounce +SECRET_KEY= +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= + diff --git a/DEPLOY_frontend.env.example b/DEPLOY_frontend.env.example new file mode 100644 index 0000000..c21fa79 --- /dev/null +++ b/DEPLOY_frontend.env.example @@ -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 + diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 58a251f..1856617 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -17,7 +17,7 @@ import logging from datetime import datetime, timedelta 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 sqlalchemy import select from slowapi import Limiter @@ -25,10 +25,11 @@ from slowapi.util import get_remote_address from app.api.deps import Database, CurrentUser 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.email_service import email_service from app.models.user import User +from app.security import set_auth_cookie, clear_auth_cookie logger = logging.getLogger(__name__) @@ -146,8 +147,8 @@ async def register( return user -@router.post("/login", response_model=Token) -async def login(user_data: UserLogin, db: Database): +@router.post("/login", response_model=LoginResponse) +async def login(user_data: UserLogin, db: Database, response: Response): """ 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}, expires_delta=access_token_expires, ) - - return Token( - access_token=access_token, - token_type="bearer", - expires_in=settings.access_token_expire_minutes * 60, + + # Set HttpOnly cookie (preferred for browser clients) + set_auth_cookie( + response=response, + 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) async def get_current_user_info(current_user: CurrentUser): diff --git a/backend/app/api/blog.py b/backend/app/api/blog.py index 17a8232..f60aa12 100644 --- a/backend/app/api/blog.py +++ b/backend/app/api/blog.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import selectinload from app.api.deps import Database, get_current_user, get_current_user_optional from app.models.user import User from app.models.blog import BlogPost +from app.services.html_sanitizer import sanitize_html router = APIRouter() @@ -194,7 +195,9 @@ async def get_blog_post( post.view_count += 1 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 ============== @@ -255,7 +258,7 @@ async def create_blog_post( post = BlogPost( title=data.title, slug=slug, - content=data.content, + content=sanitize_html(data.content), excerpt=data.excerpt, cover_image=data.cover_image, category=data.category, @@ -322,7 +325,7 @@ async def update_blog_post( # Optionally update slug if title changes # post.slug = generate_slug(data.title) if data.content is not None: - post.content = data.content + post.content = sanitize_html(data.content) if data.excerpt is not None: post.excerpt = data.excerpt if data.cover_image is not None: diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6e85d9b..35e15b3 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,21 +1,22 @@ """API dependencies.""" 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 sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.services.auth import AuthService from app.models.user import User +from app.security import AUTH_COOKIE_NAME # Security scheme -security = HTTPBearer() security_optional = HTTPBearer(auto_error=False) 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)], ) -> User: """Get current authenticated user from JWT token.""" @@ -25,7 +26,15 @@ async def get_current_user( 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) if payload is None: @@ -67,6 +76,7 @@ async def get_current_active_user( async def get_current_user_optional( + request: Request, credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security_optional)], db: Annotated[AsyncSession, Depends(get_db)], ) -> Optional[User]: @@ -75,10 +85,15 @@ async def get_current_user_optional( This allows endpoints to work for both authenticated and anonymous users, 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 - - token = credentials.credentials + payload = AuthService.decode_token(token) if payload is None: diff --git a/backend/app/api/oauth.py b/backend/app/api/oauth.py index 68598f7..8c55bea 100644 --- a/backend/app/api/oauth.py +++ b/backend/app/api/oauth.py @@ -5,15 +5,20 @@ Supports: - Google OAuth 2.0 - GitHub OAuth """ +import base64 +import hashlib +import hmac +import json import os import secrets import logging +import time from datetime import datetime, timedelta from typing import Optional from urllib.parse import urlencode import httpx -from fastapi import APIRouter, HTTPException, status, Query +from fastapi import APIRouter, HTTPException, status, Query, Request from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy import select @@ -23,6 +28,7 @@ from app.config import get_settings from app.models.user import User from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG from app.services.auth import AuthService +from app.security import set_auth_cookie, should_use_secure_cookies logger = logging.getLogger(__name__) 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") +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 ============== @@ -102,7 +225,8 @@ async def get_or_create_oauth_user( # Create new user user = User( 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, oauth_provider=provider, oauth_id=oauth_id, @@ -169,11 +293,10 @@ async def google_login(redirect: Optional[str] = Query(None)): status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Google OAuth not configured", ) - - # Store redirect URL in state - state = secrets.token_urlsafe(16) - if redirect: - state = f"{state}:{redirect}" + + redirect_path = _sanitize_redirect_path(redirect) + nonce = secrets.token_urlsafe(16) + state = _create_oauth_state("google", nonce, redirect_path) params = { "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)}" - return RedirectResponse(url=url) + response = RedirectResponse(url=url) + _set_oauth_nonce_cookie(response, "google", nonce) + return response @router.get("/google/callback") async def google_callback( + request: Request, code: str = Query(...), state: str = Query(""), db: Database = None, @@ -202,10 +328,16 @@ async def google_callback( detail="Google OAuth not configured", ) - # Parse redirect from state - redirect_path = "/command/dashboard" - if ":" in state: - _, redirect_path = state.split(":", 1) + try: + nonce, redirect_path = _verify_oauth_state(state, "google") + except Exception as e: + 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: # Exchange code for tokens @@ -257,12 +389,20 @@ async def google_callback( # Create JWT jwt_token, _ = create_jwt_for_user(user) - # Redirect to frontend with token - redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}" + # Redirect to frontend WITHOUT token in URL; set auth cookie instead. + query = {"redirect": redirect_path} if is_new: - redirect_url += "&new=true" - - return RedirectResponse(url=redirect_url) + query["new"] = "true" + redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}" + + 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: 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, detail="GitHub OAuth not configured", ) - - # Store redirect URL in state - state = secrets.token_urlsafe(16) - if redirect: - state = f"{state}:{redirect}" + + redirect_path = _sanitize_redirect_path(redirect) + nonce = secrets.token_urlsafe(16) + state = _create_oauth_state("github", nonce, redirect_path) params = { "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)}" - return RedirectResponse(url=url) + response = RedirectResponse(url=url) + _set_oauth_nonce_cookie(response, "github", nonce) + return response @router.get("/github/callback") async def github_callback( + request: Request, code: str = Query(...), state: str = Query(""), db: Database = None, @@ -311,10 +453,16 @@ async def github_callback( detail="GitHub OAuth not configured", ) - # Parse redirect from state - redirect_path = "/command/dashboard" - if ":" in state: - _, redirect_path = state.split(":", 1) + try: + nonce, redirect_path = _verify_oauth_state(state, "github") + except Exception as e: + 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: async with httpx.AsyncClient() as client: @@ -399,12 +547,19 @@ async def github_callback( # Create JWT jwt_token, _ = create_jwt_for_user(user) - # Redirect to frontend with token - redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}" + query = {"redirect": redirect_path} if is_new: - redirect_url += "&new=true" - - return RedirectResponse(url=redirect_url) + query["new"] = "true" + redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}" + + 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: logger.exception(f"GitHub OAuth error: {e}") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index c9b512d..9d2f05e 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -39,6 +39,11 @@ class Token(BaseModel): expires_in: int +class LoginResponse(BaseModel): + """Login response when using HttpOnly cookie authentication.""" + expires_in: int + + class TokenData(BaseModel): """Schema for token payload data.""" user_id: Optional[int] = None diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..9f660f2 --- /dev/null +++ b/backend/app/security.py @@ -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(), + ) + + diff --git a/backend/app/services/html_sanitizer.py b/backend/app/services/html_sanitizer.py new file mode 100644 index 0000000..6156ed2 --- /dev/null +++ b/backend/app/services/html_sanitizer.py @@ -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, + ) + + diff --git a/backend/requirements.txt b/backend/requirements.txt index 8fb4014..2734d29 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -36,6 +36,9 @@ apscheduler>=3.10.4 aiosmtplib>=3.0.2 jinja2>=3.1.2 +# HTML sanitization (XSS protection) +bleach>=6.1.0 + # Payments stripe>=7.0.0 diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index d226350..cff0e39 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -54,15 +54,27 @@ function LoginForm() { const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: 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) 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) useEffect(() => { const storedRedirect = localStorage.getItem('pounce_redirect_after_login') if (storedRedirect && !paramRedirect) { - setRedirectTo(storedRedirect) + setRedirectTo(sanitizeRedirect(storedRedirect)) } }, [paramRedirect]) @@ -101,7 +113,7 @@ function LoginForm() { localStorage.removeItem('pounce_redirect_after_login') // Redirect to intended destination or dashboard - router.push(redirectTo) + router.push(sanitizeRedirect(redirectTo)) } catch (err: unknown) { console.error('Login error:', err) if (err instanceof Error) { diff --git a/frontend/src/app/oauth/callback/page.tsx b/frontend/src/app/oauth/callback/page.tsx index cf72968..37f5920 100644 --- a/frontend/src/app/oauth/callback/page.tsx +++ b/frontend/src/app/oauth/callback/page.tsx @@ -3,7 +3,7 @@ import { useEffect, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useStore } from '@/lib/store' -import { Loader2, CheckCircle } from 'lucide-react' +import { Loader2 } from 'lucide-react' function OAuthCallbackContent() { const router = useRouter() @@ -11,32 +11,40 @@ function OAuthCallbackContent() { const { checkAuth } = useStore() useEffect(() => { - const token = searchParams.get('token') - const redirect = searchParams.get('redirect') || '/terminal/radar' + const sanitizeRedirect = (value: string | null): 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 + 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 error = searchParams.get('error') if (error) { - router.push(`/login?error=${error}`) + router.push(`/login?error=${encodeURIComponent(error)}`) return } - if (token) { - // Store the token (using 'token' key to match api.ts) - localStorage.setItem('token', token) - - // 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') - } + // Auth cookie is set by backend during OAuth callback; just refresh auth state. + checkAuth().finally(() => { + router.push(isNew ? addWelcomeFlag(redirect) : redirect) + }) }, [searchParams, router, checkAuth]) return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 87e3604..ad7d189 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -45,29 +45,10 @@ interface ApiError { } class ApiClient { - private token: string | null = null - get baseUrl(): string { 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( endpoint: string, options: RequestInit = {} @@ -78,14 +59,10 @@ class ApiClient { ...options.headers as Record, } - const token = this.getToken() - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - const response = await fetch(url, { ...options, headers, + credentials: 'include', // send HttpOnly auth cookie }) if (!response.ok) { @@ -112,19 +89,21 @@ class ApiClient { } 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', { method: 'POST', body: JSON.stringify({ email, password }), } ) - this.setToken(response.access_token) return response } async logout() { - this.setToken(null) + // Clears auth cookie on the backend + return this.request<{ message: string; success: boolean }>('/auth/logout', { + method: 'POST', + }) } async getMe() { diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index c9b1f5b..6c8c724 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -124,18 +124,16 @@ export const useStore = create((set, get) => ({ set({ isLoading: true }) try { - if (api.getToken()) { - const user = await api.getMe() - set({ user, isAuthenticated: true }) - - // Fetch in parallel for speed - await Promise.all([ - get().fetchDomains(), - get().fetchSubscription() - ]) - } + // Cookie-based auth: if cookie is present and valid, /auth/me succeeds. + const user = await api.getMe() + set({ user, isAuthenticated: true }) + + // Fetch in parallel for speed + await Promise.all([ + get().fetchDomains(), + get().fetchSubscription() + ]) } catch { - api.logout() set({ user: null, isAuthenticated: false }) } finally { set({ isLoading: false })