Move JWT from localStorage to HttpOnly cookie; fix CSRF

- JWT stored in HttpOnly, Secure, SameSite=Strict cookie — JS cannot
  read the token at all; SameSite=Strict prevents CSRF without tokens
- Non-sensitive user payload returned in response body and stored in
  localStorage for UI purposes only (not usable for auth)
- Add POST /api/auth/logout endpoint that clears the cookie server-side
- Add SECURE_COOKIES env var (default true) for local HTTP testing
- Extract login.html inline script to login.js (CSP compliance)
- Remove Authorization: Bearer header from API calls; add credentials:
  include so cookies are sent automatically
- CSP script-src includes unsafe-inline to support existing onclick
  handlers throughout the app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:57:22 -07:00
parent 6d09e40f58
commit 59f9685e2b
10 changed files with 229 additions and 165 deletions

View File

@@ -4,8 +4,7 @@ from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status from fastapi import Cookie, Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
@@ -14,9 +13,10 @@ from models import User
SECRET_KEY = os.environ["JWT_SECRET"] SECRET_KEY = os.environ["JWT_SECRET"]
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 30 ACCESS_TOKEN_EXPIRE_DAYS = 30
COOKIE_NAME = "token"
SECURE_COOKIES = os.environ.get("SECURE_COOKIES", "true").lower() == "true"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -41,28 +41,44 @@ def create_access_token(user_id: int, username: str, is_admin: bool, user_timezo
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
async def get_token_payload( def set_auth_cookie(response: Response, token: str) -> None:
token: str = Depends(oauth2_scheme), response.set_cookie(
) -> dict: key=COOKIE_NAME,
try: value=token,
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) httponly=True,
except JWTError: secure=SECURE_COOKIES,
raise HTTPException( samesite="strict",
status_code=status.HTTP_401_UNAUTHORIZED, max_age=ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
detail="Could not validate credentials", path="/",
headers={"WWW-Authenticate": "Bearer"}, )
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, httponly=True, secure=SECURE_COOKIES, samesite="strict", path="/")
def token_to_user_payload(token: str) -> dict:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return {
"sub": payload["sub"],
"username": payload["username"],
"is_admin": payload["is_admin"],
"timezone": payload["timezone"],
"exp": payload["exp"],
"admin_id": payload.get("admin_id"),
}
async def get_current_user( async def get_current_user(
token: str = Depends(oauth2_scheme), token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ) -> User:
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) )
if not token:
raise credentials_exception
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id_str: Optional[str] = payload.get("sub") user_id_str: Optional[str] = payload.get("sub")
@@ -85,3 +101,20 @@ async def get_current_admin(current_user: User = Depends(get_current_user)) -> U
detail="Admin access required", detail="Admin access required",
) )
return current_user return current_user
async def get_token_payload(
token: Optional[str] = Cookie(default=None, alias=COOKIE_NAME),
) -> dict:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)

View File

@@ -1,13 +1,13 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import User from models import User
from schemas import UserCreate, UserOut, ResetPasswordRequest, TokenResponse from schemas import UserCreate, UserOut, ResetPasswordRequest, AuthResponse
from auth import hash_password, create_access_token, get_current_admin, get_token_payload from auth import hash_password, create_access_token, get_current_admin, get_token_payload, set_auth_cookie, token_to_user_payload
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
logger = logging.getLogger("yolkbook") logger = logging.getLogger("yolkbook")
@@ -104,9 +104,10 @@ def delete_user(
db.commit() db.commit()
@router.post("/users/{user_id}/impersonate", response_model=TokenResponse) @router.post("/users/{user_id}/impersonate", response_model=AuthResponse)
def impersonate_user( def impersonate_user(
user_id: int, user_id: int,
response: Response,
current_admin: User = Depends(get_current_admin), current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -114,12 +115,14 @@ def impersonate_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=current_admin.id) token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=current_admin.id)
set_auth_cookie(response, token)
logger.warning("Admin '%s' (id=%d) is impersonating user '%s' (id=%d).", current_admin.username, current_admin.id, user.username, user.id) logger.warning("Admin '%s' (id=%d) is impersonating user '%s' (id=%d).", current_admin.username, current_admin.id, user.username, user.id)
return TokenResponse(access_token=token) return AuthResponse(user=token_to_user_payload(token))
@router.post("/unimpersonate", response_model=TokenResponse) @router.post("/unimpersonate", response_model=AuthResponse)
def unimpersonate( def unimpersonate(
response: Response,
payload: dict = Depends(get_token_payload), payload: dict = Depends(get_token_payload),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -130,5 +133,6 @@ def unimpersonate(
if not admin or admin.is_disabled or not admin.is_admin: if not admin or admin.is_disabled or not admin.is_admin:
raise HTTPException(status_code=403, detail="Original admin account is no longer valid") raise HTTPException(status_code=403, detail="Original admin account is no longer valid")
token = create_access_token(admin.id, admin.username, admin.is_admin, admin.timezone) token = create_access_token(admin.id, admin.username, admin.is_admin, admin.timezone)
set_auth_cookie(response, token)
logger.warning("Admin '%s' (id=%d) ended impersonation session.", admin.username, admin.id) logger.warning("Admin '%s' (id=%d) ended impersonation session.", admin.username, admin.id)
return TokenResponse(access_token=token) return AuthResponse(user=token_to_user_payload(token))

View File

@@ -1,23 +1,28 @@
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import User from models import User
from schemas import LoginRequest, TokenResponse, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate from schemas import LoginRequest, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate, AuthResponse
from auth import verify_password, hash_password, create_access_token, get_current_user from auth import (
verify_password, hash_password, create_access_token, get_current_user,
set_auth_cookie, clear_auth_cookie, token_to_user_payload,
)
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
def _make_token(user: User) -> str: def _issue(response: Response, user: User, admin_id=None) -> AuthResponse:
return create_access_token(user.id, user.username, user.is_admin, user.timezone) token = create_access_token(user.id, user.username, user.is_admin, user.timezone, admin_id=admin_id)
set_auth_cookie(response, token)
return AuthResponse(user=token_to_user_payload(token))
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=AuthResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)): def login(body: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.scalars(select(User).where(User.username == body.username)).first() user = db.scalars(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.hashed_password): if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException( raise HTTPException(
@@ -29,15 +34,14 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Account is disabled. Contact your administrator.", detail="Account is disabled. Contact your administrator.",
) )
return TokenResponse(access_token=_make_token(user)) return _issue(response, user)
@router.post("/register", response_model=TokenResponse, status_code=201) @router.post("/register", response_model=AuthResponse, status_code=201)
def register(body: UserCreate, db: Session = Depends(get_db)): def register(body: UserCreate, response: Response, db: Session = Depends(get_db)):
existing = db.scalars(select(User).where(User.username == body.username)).first() existing = db.scalars(select(User).where(User.username == body.username)).first()
if existing: if existing:
raise HTTPException(status_code=409, detail="Username already taken") raise HTTPException(status_code=409, detail="Username already taken")
# Default timezone to UTC; user can change it in settings
user = User( user = User(
username=body.username, username=body.username,
hashed_password=hash_password(body.password), hashed_password=hash_password(body.password),
@@ -47,7 +51,13 @@ def register(body: UserCreate, db: Session = Depends(get_db)):
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return TokenResponse(access_token=_make_token(user)) return _issue(response, user)
@router.post("/logout")
def logout(response: Response):
clear_auth_cookie(response)
return {"detail": "Logged out"}
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
@@ -68,18 +78,18 @@ def change_password(
return {"detail": "Password updated"} return {"detail": "Password updated"}
@router.put("/timezone", response_model=TokenResponse) @router.put("/timezone", response_model=AuthResponse)
def update_timezone( def update_timezone(
body: TimezoneUpdate, body: TimezoneUpdate,
response: Response,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
try: try:
ZoneInfo(body.timezone) # validate it's a real IANA timezone ZoneInfo(body.timezone)
except ZoneInfoNotFoundError: except ZoneInfoNotFoundError:
raise HTTPException(status_code=400, detail=f"Unknown timezone: {body.timezone}") raise HTTPException(status_code=400, detail=f"Unknown timezone: {body.timezone}")
current_user.timezone = body.timezone current_user.timezone = body.timezone
db.commit() db.commit()
db.refresh(current_user) db.refresh(current_user)
# Return a fresh token with the updated timezone embedded return _issue(response, current_user)
return TokenResponse(access_token=_make_token(current_user))

View File

@@ -14,6 +14,17 @@ class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
class UserPayload(BaseModel):
sub: str
username: str
is_admin: bool
timezone: str
exp: int
admin_id: Optional[int] = None
class AuthResponse(BaseModel):
user: UserPayload
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
current_password: str current_password: str
new_password: str = Field(min_length=10) new_password: str = Field(min_length=10)

View File

@@ -118,7 +118,7 @@ async function toggleUser(id, disable) {
async function impersonateUser(id) { async function impersonateUser(id) {
try { try {
const data = await API.post(`/api/admin/users/${id}/impersonate`, {}); const data = await API.post(`/api/admin/users/${id}/impersonate`, {});
Auth.setToken(data.access_token); Auth.setToken(data.user);
window.location.href = '/'; window.location.href = '/';
} catch (err) { } catch (err) {
showMessage(document.getElementById('msg'), err.message, 'error'); showMessage(document.getElementById('msg'), err.message, 'error');

View File

@@ -2,14 +2,11 @@
const API = { const API = {
async _fetch(url, options = {}) { async _fetch(url, options = {}) {
const token = localStorage.getItem('token');
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(url, { credentials: 'include', headers, ...options });
const res = await fetch(url, { headers, ...options });
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem('token'); localStorage.removeItem('user');
window.location.href = '/login'; window.location.href = '/login';
return; return;
} }

View File

@@ -1,28 +1,25 @@
// auth.js — authentication utilities used by every authenticated page // auth.js — authentication utilities used by every authenticated page
const Auth = { const Auth = {
getToken() { // Store/retrieve the non-sensitive user payload (not the token itself)
return localStorage.getItem('token'); setToken(userData) {
}, localStorage.setItem('user', JSON.stringify(userData));
setToken(token) {
localStorage.setItem('token', token);
}, },
removeToken() { removeToken() {
localStorage.removeItem('token'); localStorage.removeItem('user');
}, },
getUser() { getUser() {
const token = this.getToken(); const raw = localStorage.getItem('user');
if (!token) return null; if (!raw) return null;
try { try {
const payload = JSON.parse(atob(token.split('.')[1])); const user = JSON.parse(raw);
if (payload.exp < Date.now() / 1000) { if (user.exp < Date.now() / 1000) {
this.removeToken(); this.removeToken();
return null; return null;
} }
return payload; return user;
} catch (_) { } catch (_) {
return null; return null;
} }
@@ -37,7 +34,10 @@ const Auth = {
return user; return user;
}, },
logout() { async logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (_) { /* best-effort */ }
this.removeToken(); this.removeToken();
window.location.href = '/login'; window.location.href = '/login';
}, },
@@ -46,7 +46,7 @@ const Auth = {
async function returnToAdmin() { async function returnToAdmin() {
try { try {
const data = await API.post('/api/admin/unimpersonate', {}); const data = await API.post('/api/admin/unimpersonate', {});
Auth.setToken(data.access_token); Auth.setToken(data.user);
window.location.href = '/admin'; window.location.href = '/admin';
} catch (err) { } catch (err) {
Auth.logout(); Auth.logout();
@@ -198,7 +198,7 @@ async function submitTimezone() {
const msgEl = document.getElementById('settings-msg'); const msgEl = document.getElementById('settings-msg');
try { try {
const data = await API.put('/api/auth/timezone', { timezone: tz }); const data = await API.put('/api/auth/timezone', { timezone: tz });
Auth.setToken(data.access_token); Auth.setToken(data.user);
msgEl.textContent = `Timezone saved: ${tz.replace(/_/g, ' ')}`; msgEl.textContent = `Timezone saved: ${tz.replace(/_/g, ' ')}`;
msgEl.className = 'message success visible'; msgEl.className = 'message success visible';
setTimeout(() => { msgEl.className = 'message'; }, 3000); setTimeout(() => { msgEl.className = 'message'; }, 3000);

110
nginx/html/js/login.js Normal file
View File

@@ -0,0 +1,110 @@
// login.js — login / register page logic
// Redirect if already logged in
(function () {
const raw = localStorage.getItem('user');
if (raw) {
try {
const user = JSON.parse(raw);
if (user.exp > Date.now() / 1000) {
window.location.href = '/';
return;
}
} catch (_) {}
localStorage.removeItem('user');
}
})();
function showLogin() {
document.getElementById('register-panel').style.display = 'none';
document.getElementById('login-panel').style.display = 'block';
document.getElementById('username').focus();
}
function showRegister() {
document.getElementById('login-panel').style.display = 'none';
document.getElementById('register-panel').style.display = 'block';
document.getElementById('reg-username').focus();
}
function showError(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message error visible';
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('show-register-link').addEventListener('click', (e) => {
e.preventDefault();
showRegister();
});
document.getElementById('show-login-link').addEventListener('click', (e) => {
e.preventDefault();
showLogin();
});
// ── Login ──
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Signing in…';
document.getElementById('login-msg').className = 'message';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.status === 429) { showError('login-msg', 'Too many attempts — please wait a minute and try again.'); return; }
if (!res.ok) { showError('login-msg', data.detail || 'Login failed'); return; }
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/';
} catch (err) {
showError('login-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
// ── Register ──
document.getElementById('reg-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('reg-btn');
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
if (password !== confirm) { showError('reg-msg', 'Passwords do not match'); return; }
btn.disabled = true;
btn.textContent = 'Creating account…';
document.getElementById('reg-msg').className = 'message';
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) { showError('reg-msg', data.detail || 'Registration failed'); return; }
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/';
} catch (err) {
showError('reg-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
});

View File

@@ -27,7 +27,7 @@
<button type="submit" class="btn btn-primary" style="width:100%" id="login-btn">Sign In</button> <button type="submit" class="btn btn-primary" style="width:100%" id="login-btn">Sign In</button>
</form> </form>
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)"> <p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
No account? <a href="#" onclick="showRegister()">Create one</a> No account? <a href="#" id="show-register-link">Create one</a>
</p> </p>
</div> </div>
@@ -51,112 +51,11 @@
<button type="submit" class="btn btn-primary" style="width:100%" id="reg-btn">Create Account</button> <button type="submit" class="btn btn-primary" style="width:100%" id="reg-btn">Create Account</button>
</form> </form>
<p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)"> <p style="text-align:center;margin-top:1rem;font-size:0.9rem;color:var(--muted)">
Already have an account? <a href="#" onclick="showLogin()">Sign in</a> Already have an account? <a href="#" id="show-login-link">Sign in</a>
</p> </p>
</div> </div>
</div> </div>
<script> <script src="/js/login.js"></script>
// Redirect if already logged in
(function () {
const token = localStorage.getItem('token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp > Date.now() / 1000) {
window.location.href = '/';
return;
}
} catch (_) {}
localStorage.removeItem('token');
}
})();
function showLogin() {
document.getElementById('register-panel').style.display = 'none';
document.getElementById('login-panel').style.display = 'block';
document.getElementById('username').focus();
}
function showRegister() {
document.getElementById('login-panel').style.display = 'none';
document.getElementById('register-panel').style.display = 'block';
document.getElementById('reg-username').focus();
}
function showError(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message error visible';
}
function showSuccess(elId, text) {
const el = document.getElementById(elId);
el.textContent = text;
el.className = 'message success visible';
}
// ── Login ──
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.textContent = 'Signing in…';
document.getElementById('login-msg').className = 'message';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.status === 429) { showError('login-msg', 'Too many attempts — please wait a minute and try again.'); return; }
if (!res.ok) { showError('login-msg', data.detail || 'Login failed'); return; }
localStorage.setItem('token', data.access_token);
window.location.href = '/';
} catch (err) {
showError('login-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
// ── Register ──
document.getElementById('reg-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('reg-btn');
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
if (password !== confirm) { showError('reg-msg', 'Passwords do not match'); return; }
btn.disabled = true;
btn.textContent = 'Creating account…';
document.getElementById('reg-msg').className = 'message';
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) { showError('reg-msg', data.detail || 'Registration failed'); return; }
localStorage.setItem('token', data.access_token);
window.location.href = '/';
} catch (err) {
showError('reg-msg', 'Could not reach the server. Please try again.');
} finally {
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
</script>
</body> </body>
</html> </html>

View File

@@ -31,7 +31,7 @@ http {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'" always;
# ── Static files ────────────────────────────────────────────────────── # ── Static files ──────────────────────────────────────────────────────
location / { location / {