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:
2026-03-22 00:00:14 -07:00
parent be86cae7fa
commit 3022bc328b
11 changed files with 228 additions and 30 deletions

View File

@@ -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;

View File

@@ -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 }
})

View File

@@ -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' })