Security hardening: go-live review fixes
- TV tokens upgraded from 4 to 6 digits; Regen Token button in Admin - Nginx rate limiting on TV dashboard and WebSocket endpoints - Login lockout after 5 failed attempts (15 min); clears on admin password reset - HSTS header added; CSP unsafe-inline removed from script-src; CORS restricted to explicit methods/headers - Dependency CVE fixes: PyJWT 2.12.0, aiomysql 0.3.0, cryptography 46.0.5, python-multipart 0.0.22 - datetime.utcnow() replaced with datetime.now(timezone.utc) throughout - SQL identifier whitelist for startup migration queries - README updated: security notes section, lockout docs, token regen, NPM proxy guidance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,46 @@
|
||||
# Rate limiting zones — included inside http{} block
|
||||
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=tv_limit:10m rate=10r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_tokens off;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:;" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Rate-limited auth endpoints (checked before the generic /api/ block)
|
||||
location ~ ^/api/(auth/(login|register)|admin/login)$ {
|
||||
limit_req zone=auth_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Rate-limited TV dashboard endpoint (public, token-based)
|
||||
location ~ ^/api/dashboard/ {
|
||||
limit_req zone=tv_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# API proxy → FastAPI backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
@@ -13,6 +51,8 @@ server {
|
||||
|
||||
# WebSocket proxy → FastAPI backend
|
||||
location /ws/ {
|
||||
limit_req zone=tv_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
@@ -32,9 +32,16 @@ export const useChildrenStore = defineStore('children', () => {
|
||||
children.value = children.value.filter((c) => c.id !== id)
|
||||
}
|
||||
|
||||
async function regenerateToken(id) {
|
||||
const res = await api.post(`/api/children/${id}/regenerate-token`)
|
||||
const idx = children.value.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) children.value[idx] = res.data
|
||||
return res.data
|
||||
}
|
||||
|
||||
function setActiveChild(child) {
|
||||
activeChild.value = child
|
||||
}
|
||||
|
||||
return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, setActiveChild }
|
||||
return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, regenerateToken, setActiveChild }
|
||||
})
|
||||
|
||||
@@ -48,11 +48,13 @@
|
||||
<div class="item-color" :style="{ background: child.color }"></div>
|
||||
<span class="item-name">{{ child.name }}</span>
|
||||
<span class="item-meta">{{ child.is_active ? 'Active' : 'Inactive' }}</span>
|
||||
<span class="item-meta token-meta" title="TV token">📺 {{ child.tv_token }}</span>
|
||||
<div class="item-actions">
|
||||
<button class="btn-sm" @click="startEditChild(child)">Edit</button>
|
||||
<button class="btn-sm" @click="toggleChild(child)">
|
||||
{{ child.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
<button class="btn-sm" @click="regenToken(child)" title="Generate a new TV token (old URL will stop working)">Regen Token</button>
|
||||
<button class="btn-sm btn-danger" @click="deleteChild(child.id)">Delete</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -536,6 +538,12 @@ async function deleteChild(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function regenToken(child) {
|
||||
if (confirm(`Regenerate TV token for ${child.name}? The old TV URL will stop working immediately.`)) {
|
||||
await childrenStore.regenerateToken(child.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Subjects
|
||||
const showSubjectForm = ref(false)
|
||||
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
|
||||
|
||||
Reference in New Issue
Block a user