diff --git a/VERSION b/VERSION index 94b83d8..56c76c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.57 +0.2.58 diff --git a/frontend/e2e/route-smoke.e2e.ts b/frontend/e2e/route-smoke.e2e.ts index c9f16fa..0bc2c1c 100644 --- a/frontend/e2e/route-smoke.e2e.ts +++ b/frontend/e2e/route-smoke.e2e.ts @@ -44,6 +44,35 @@ test.describe('authenticated route and deep-link smoke', () => { await expect(page.getByRole('button', { name: 'Anmelden' })).toBeVisible() }) + test('does not present the rolling login window as a lock while attempts remain', async ({ page }) => { + await mockNexusApi(page, { authenticated: false }) + await page.route('**/api/v1/auth/login', async route => { + await route.fulfill({ + status: 401, + contentType: 'application/problem+json', + headers: { + 'X-RateLimit-Remaining': '4', + 'X-RateLimit-Reset': String(Math.ceil(Date.now() / 1000) + 60), + }, + body: JSON.stringify({ + message: 'Invalid email or password.', + remaining: 4, + retryAfterSeconds: 60, + }), + }) + }) + + await page.goto('/login') + await page.getByLabel('E-Mail').fill('release-smoke@example.invalid') + await page.getByLabel('Passwort', { exact: true }).fill('not-a-real-password') + await page.getByRole('button', { name: 'Anmelden' }).click() + + await expect(page.getByRole('alert')).toContainText('4 Versuche verbleibend') + await expect(page.getByText(/Entsperrt in/)).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Anmelden' })).toBeEnabled() + await expect(page.getByRole('button', { name: /Gesperrt/ })).toHaveCount(0) + }) + test('renders the core owner deep links without a client-side exception', async ({ page }) => { test.setTimeout(90_000) await mockNexusApi(page, { role: 'owner' }) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4b90039..23a61ab 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,16 +5,16 @@ server { root /usr/share/nginx/html; index index.html; - add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; - add_header Referrer-Policy "no-referrer" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "DENY" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - # Gehashte Assets: 1 Jahr cachen (immutable wg. Content-Hash im Dateinamen) location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; } # SPA-Entry nie cachen @@ -22,6 +22,12 @@ server { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; add_header Expires "0"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; } # Bridge-Endpunkte (Agent-zu-Backend): separater Pfad ohne CSP-Einschränkungen @@ -66,6 +72,12 @@ server { location / { try_files $uri $uri/ /index.html; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; } } diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 8a0f0d3..135b9f6 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -65,8 +65,10 @@ async function submit() { if (reason instanceof Error) { error.value = reason.message - // Start countdown if rate-limited - if (auth.retryAfterSeconds > 0) { + // The reset timestamp also describes the rolling attempt window after a + // regular 401. Only present a lock countdown once the backend has + // actually exhausted the remaining attempts. + if (auth.isRateLimited && auth.retryAfterSeconds > 0) { startCountdown(auth.retryAfterSeconds) } } else { @@ -152,14 +154,14 @@ async function submit() {