From b4b40e5c2c446d59191cd819b2c5a9b96ad8c6cd Mon Sep 17 00:00:00 2001 From: Kolkman Date: Wed, 26 Aug 2026 20:46:48 +0200 Subject: [PATCH] Logout now requires Authorization: Bearer . --- README.md | 7 +++++++ Security-audit.md | 10 ++++++---- VIBE/CHAT_LOG.md | 6 ++++++ VIBE/PROMPTS.md | 1 + XPI/unsigned/LinkLog-0.1.0.xpi | Bin 50359 -> 50357 bytes backend/app/api/auth.py | 10 ++++++---- backend/tests/test_api.py | 14 ++++++++++++-- frontend/static/logout.js | 3 +-- webextension/options.js | 3 +-- 9 files changed, 40 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d1cfb30..cae5278 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,13 @@ curl -X POST http://localhost:8000/api/auth/refresh \\ Refresh-token reuse or a mismatched device ID returns `401` and revokes the token family. Signing out revokes the token family, while changing the password or completing a password reset revokes all sessions for the user. +Sign out with the access token in the bearer header: + +```sh +curl -X POST http://localhost:8000/api/auth/logout \\ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' +``` + Submit a link using the returned access token: ```sh diff --git a/Security-audit.md b/Security-audit.md index e6997ad..7a99bc8 100644 --- a/Security-audit.md +++ b/Security-audit.md @@ -42,13 +42,15 @@ The application should remain behind the production reverse proxy, with real DNS ### SA-001: Logout uses non-standard token transport -**Severity:** High -**Evidence:** `POST /api/auth/logout` accepts `{"token": ...}` in the JSON request body, and the web frontend sends the access token this way. +**Severity:** High, remediated in current worktree +**Evidence before remediation:** `POST /api/auth/logout` accepted `{"token": ...}` in the JSON request body, and the web frontend sent the access token this way. **Impact:** Request bodies may be captured by debugging middleware, application logs, or monitoring systems. The endpoint also diverges from the bearer-header contract used elsewhere, increasing the chance of inconsistent token handling. -**Recommendation:** Make logout use `Authorization: Bearer ` and revoke the authenticated token or its family server-side. If a compatibility period is required, support both forms temporarily, prefer the header, and remove the body form after client migration. Add tests proving body-only tokens are rejected once compatibility is removed. +**Current state:** Logout requires `Authorization: Bearer `, rejects body-only tokens with `401`, and revokes the token family server-side. The web frontend and Firefox extension send the header; regression coverage verifies access and refresh tokens are invalid after logout. -**Priority:** High. +**Recommendation:** Keep logout header-only, retain family revocation, avoid logging authorization headers, and rotate legacy sessions issued before this change. + +**Priority:** Completed in code; legacy session rotation and log hygiene remain. ### SA-002: Raw infrastructure errors are returned to clients diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 7256f5a..17de7ae 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1,5 +1,11 @@ # Chat Log +### User +Remedy SA-001: Logout uses non-standard token transport. + +### Assistant outcome +Migrated logout to require `Authorization: Bearer `, updated the web frontend and Firefox extension, and added regression coverage proving body-only logout is rejected while header logout revokes the access token and refresh-token family. Updated SA-001, the checklist, and README examples. + ### User Perform a new security audit overwriting Security-audit.md with new and remaining issues. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 2c6ca63..1f4c18b 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -200,6 +200,7 @@ 194. Replace LINKLOG_TOKEN_EXPIRY_DAYS with LINKLOG_TOKEN_EXPIRY_MINUTES, add LINKLOG_REFRESH_TOKEN_EXPIRY_DAYS to production Compose, and add a CI configuration consistency test. 195. Perform a new security audit overwriting Security-audit.md with new and remaining issues. 195. Update SA-012 and README for the implemented refresh-token lifecycle, revocation behavior, and refresh endpoint. +196. Remedy SA-001: migrate logout from JSON token transport to the Authorization bearer header. ## Future entries diff --git a/XPI/unsigned/LinkLog-0.1.0.xpi b/XPI/unsigned/LinkLog-0.1.0.xpi index 6657fbfc86593003ea9bd0b2e53463ec88210bf2..a6196d2ce8b82fad7a59bb9c9542bca39ba1c8a5 100644 GIT binary patch delta 1875 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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 7c88a88..d570bb7 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -3,7 +3,7 @@ from uuid import uuid4 -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Header, HTTPException, Request from pydantic import BaseModel from backend.app.api.dependencies import get_current_user @@ -119,10 +119,12 @@ def reset_password_endpoint(payload: PasswordResetRequest): @router.post('/logout') -def logout(payload: dict): - token = payload.get('token') +def logout(authorization: str | None = Header(default=None)): + if not authorization or not authorization.startswith('Bearer '): + raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') + token = authorization.replace('Bearer ', '', 1).strip() if not token: - raise HTTPException(status_code=400, detail='Token is required') + raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') revoked = revoke_token(token) if not revoked: raise HTTPException(status_code=404, detail='Token not found or already revoked') diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a3b72ac..c9dab5d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -55,6 +55,17 @@ def test_login_returns_token(): assert client.get('/api/auth/me', params={'token': payload['access_token']}).status_code == 401 +def test_logout_requires_bearer_header_and_revokes_token_family(): + login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json() + token = login['access_token'] + headers = {'Authorization': f'Bearer {token}'} + assert client.post('/api/auth/logout', json={'token': token}).status_code == 401 + assert client.get('/api/auth/me', headers=headers).status_code == 200 + assert client.post('/api/auth/logout', headers=headers).status_code == 200 + assert client.get('/api/auth/me', headers=headers).status_code == 401 + assert client.post('/api/auth/refresh', json={'refresh_token': login['refresh_token'], 'device_id': login['device_id']}).status_code == 401 + + def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success(): email = f'unknown-{uuid4().hex}@example.com' for attempt in range(5): @@ -643,8 +654,7 @@ def test_only_link_owner_can_edit_link(): def test_logout_revokes_token_and_admin_can_list_plugins(): headers = login_headers() - token = headers['Authorization'].removeprefix('Bearer ') - assert client.post('/api/auth/logout', json={'token': token}).status_code == 200 + assert client.post('/api/auth/logout', headers=headers).status_code == 200 revoked_response = client.post('/api/links', headers=headers, json={ 'title': 'Should fail', diff --git a/frontend/static/logout.js b/frontend/static/logout.js index f1e6063..ac971f3 100644 --- a/frontend/static/logout.js +++ b/frontend/static/logout.js @@ -12,8 +12,7 @@ logoutButton.addEventListener('click', async () => { if (token) { await fetch('/api/auth/logout', { method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({token}), + headers: {Authorization: `Bearer ${token}`}, }).catch(() => undefined); } diff --git a/webextension/options.js b/webextension/options.js index e5d927e..a5631bb 100644 --- a/webextension/options.js +++ b/webextension/options.js @@ -186,8 +186,7 @@ async function signOut() { if (sessionSettings.accessToken && settings.backendUrl && await hasBackendPermission(backendUrl)) { await fetch(`${backendUrl}/api/auth/logout`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: sessionSettings.accessToken }), + headers: {Authorization: `Bearer ${sessionSettings.accessToken}`}, }).catch(() => undefined); } await clearSession();