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
|
.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/
|
||||||
|
|||||||
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 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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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
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
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user