security: harden dashboard (SSH keys, auth, uploads, CORS, non-root)
Remove SSH private keys from git, add SECRET_KEY validation, move WS auth from query string to first message, add session limits/idle timeout, PBKDF2 Fernet key, refresh token rotation, TOTP replay protection, file upload size limit + filename sanitization, symlink safety check, restrict CORS methods, IP-gate OpenClaw token, run container as non-root, rate-limit refresh/passkey endpoints, sanitize Gitea path params. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,10 +74,10 @@ async def login(creds: LoginRequest, request: Request):
|
||||
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Incorrect username or password"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
# Check 2FA
|
||||
totp_secret = auth.load_totp_secret()
|
||||
if totp_secret:
|
||||
@@ -92,7 +92,7 @@ async def login(creds: LoginRequest, request: Request):
|
||||
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Invalid 2FA Code"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid 2FA code",
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -115,7 +115,8 @@ async def read_users_me(current_user: str = Depends(auth.get_current_user)):
|
||||
return {"username": current_user, "has_2fa": bool(totp_secret)}
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(req: RefreshRequest):
|
||||
@limiter.limit("10/minute")
|
||||
async def refresh_token(req: RefreshRequest, request: Request):
|
||||
try:
|
||||
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||
if payload.get("type") != "refresh":
|
||||
@@ -123,8 +124,11 @@ async def refresh_token(req: RefreshRequest):
|
||||
username = payload.get("sub")
|
||||
if username != config.ADMIN_USER:
|
||||
raise HTTPException(status_code=401, detail="Invalid user")
|
||||
if not auth.verify_token_version(payload.get("tv", -1)):
|
||||
raise HTTPException(status_code=401, detail="Token revoked")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
auth.increment_token_version()
|
||||
access_token = auth.create_access_token(
|
||||
data={"sub": username},
|
||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
@@ -217,7 +221,8 @@ async def passkey_register_verify(request: Request, current_user: str = Depends(
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/passkey/login/options")
|
||||
async def passkey_login_options():
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_options(request: Request):
|
||||
creds = auth.load_passkey_credentials()
|
||||
if not creds:
|
||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
||||
@@ -231,6 +236,7 @@ async def passkey_login_options():
|
||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||
|
||||
@router.post("/passkey/login/verify")
|
||||
@limiter.limit("10/minute")
|
||||
async def passkey_login_verify(request: Request):
|
||||
body = await request.json()
|
||||
challenge = _get_challenge()
|
||||
|
||||
Reference in New Issue
Block a user