security: purge leaked deploy files, cookie auth, sanitize blog
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@ -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/
|
||||
|
||||
55
DEPLOY_backend.env.example
Normal file
55
DEPLOY_backend.env.example
Normal 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=
|
||||
|
||||
7
DEPLOY_frontend.env.example
Normal file
7
DEPLOY_frontend.env.example
Normal 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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
64
backend/app/security.py
Normal file
64
backend/app/security.py
Normal 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(),
|
||||
)
|
||||
|
||||
|
||||
51
backend/app/services/html_sanitizer.py
Normal file
51
backend/app/services/html_sanitizer.py
Normal 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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
@ -78,14 +59,10 @@ class ApiClient {
|
||||
...options.headers as Record<string, string>,
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@ -124,18 +124,16 @@ export const useStore = create<AppState>((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 })
|
||||
|
||||
Reference in New Issue
Block a user