Fix cross-origin API access, bcrypt login break, and weak secret key
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / docker-build (push) Has been cancelled
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled
Test / security-scan (push) Has been cancelled

The frontend only worked from localhost:3000 — the browser made absolute
cross-origin calls to localhost:8000, which (a) points at the visitor's own
machine when accessed via the LAN IP or Cloudflare tunnel, and (b) was blocked
by CORS (backend only allowed localhost:3000). Now all API calls are
same-origin and proxied to the backend:

- api.ts: default API base to same-origin ("") instead of localhost:8000
- docker-compose: NEXT_PUBLIC_API_URL="" (client uses relative /api)
- next.config.ts: server-side rewrite uses API_INTERNAL_URL (http://backend:8000
  inside Docker) so :3000 direct access proxies correctly
- main.py: broaden CORS via allow_origin_regex (localhost + private LAN) and an
  optional CORS_ORIGINS env var, as defense-in-depth for direct :8000 access

Login was returning 500: passlib 1.7.4 is incompatible with the bcrypt 5.0.0
actually installed in the image (requirements pin 4.1.2, but the image drifted).
- security.py: call bcrypt directly, truncating to 72 bytes; existing $2b$
  hashes verify unchanged, and it works under both bcrypt 4.x and 5.x

Security: SECRET_KEY was a known placeholder string while the app is exposed on
the LAN and via Cloudflare tunnel — anyone could forge admin JWTs. Regenerated
to a strong random value in backend/.env (gitignored; not committed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-07-07 15:13:23 +00:00
parent 2bdc085bc3
commit c44fa6fcda
6 changed files with 40 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
SECRET_KEY: str = "dev-secret-key-change-in-production"
ALGORITHM: str = "HS256"
CORS_ORIGINS: Optional[str] = None # comma-separated extra allowed origins
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
AI_PROVIDER: str = "openai"

View File

@@ -1,21 +1,34 @@
from datetime import datetime, timedelta
from typing import Optional, Any
import bcrypt
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
# We call bcrypt directly rather than through passlib: passlib 1.7.x is
# incompatible with bcrypt >= 4.1 (its version shim raises on the modern
# library). bcrypt only uses the first 72 bytes of a password, so we truncate
# to that to avoid the ValueError bcrypt 5.x raises on longer inputs. Existing
# $2b$ hashes (created via passlib's bcrypt backend) verify unchanged.
_BCRYPT_MAX_BYTES = 72
def _to_bytes(password: str) -> bytes:
return password.encode("utf-8")[:_BCRYPT_MAX_BYTES]
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
try:
return bcrypt.checkpw(_to_bytes(plain), hashed.encode("utf-8"))
except (ValueError, TypeError):
return False
def hash_password(password: str) -> str:
return pwd_context.hash(password)
return bcrypt.hashpw(_to_bytes(password), bcrypt.gensalt()).decode("utf-8")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:

View File

@@ -22,9 +22,16 @@ app = FastAPI(
lifespan=lifespan,
)
# The app is normally served same-origin (nginx proxies /api to the backend),
# so CORS is not exercised in the primary flow. This allowlist exists for direct
# browser access to :8000 during development and for any explicitly configured
# origins. Extra origins can be added via the CORS_ORIGINS env var (comma-separated).
_extra_origins = [o.strip() for o in (settings.CORS_ORIGINS or "").split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://frontend:3000"],
allow_origins=["http://localhost:3000", "http://frontend:3000", *_extra_origins],
# Also allow localhost and private-network hosts on any port (dev convenience).
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)(:\d+)?$",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -5,7 +5,11 @@ const nextConfig: NextConfig = {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
// Server-side proxy for direct :3000 access. Uses the internal service
// hostname inside Docker (API_INTERNAL_URL=http://backend:8000); falls
// back to localhost for non-container dev. Not used when served via
// nginx, which proxies /api itself.
destination: `${process.env.API_INTERNAL_URL || "http://localhost:8000"}/api/:path*`,
},
];
},

View File

@@ -1,4 +1,8 @@
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
// Empty string = same-origin. API calls go to /api/... on whatever host served
// the page, and are proxied to the backend by nginx (port 80) or the Next.js
// rewrite in next.config.ts (port 3000). This keeps everything same-origin so
// it works via localhost, the LAN IP, and the Cloudflare tunnel with no CORS.
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
let authToken: string | null = null;

View File

@@ -43,7 +43,10 @@ services:
dockerfile: ../infra/Dockerfile.frontend
container_name: trustos_frontend
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
# Empty = same-origin API calls (proxied to backend by nginx / Next rewrite).
# Works via localhost, LAN IP, and Cloudflare tunnel without CORS.
NEXT_PUBLIC_API_URL: ""
API_INTERNAL_URL: "http://backend:8000"
ports:
- "3000:3000"
volumes: