Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6772bf7107 | ||
|
|
0287305884 |
@@ -108,7 +108,7 @@ Every push to `main` also runs `.gitea/workflows/development.yml` and publishes
|
||||
|
||||
Appending a username to the root URL, such as `/alice`, opens that user's public feed and profile information.
|
||||
|
||||
Configuration APIs require a bearer token returned by the login endpoint. Users authenticate with their email address; the username remains the public presentation identity used in profiles and feed URLs. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account.
|
||||
Configuration APIs require a bearer token returned by the login endpoint. Send it in the `Authorization: Bearer ...` header; query-string tokens are not accepted. Users authenticate with their email address; the username remains the public presentation identity used in profiles and feed URLs. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account.
|
||||
|
||||
Users can change their password from the profile page. The current password is required, new passwords must contain at least 8 characters, and the endpoint is `PUT /api/user/password`.
|
||||
|
||||
|
||||
+7
-6
@@ -54,13 +54,14 @@ These findings are prioritized below. Severity describes the potential security
|
||||
|
||||
### SA-002: Bearer tokens accepted in query strings
|
||||
|
||||
**Severity:** High
|
||||
**Evidence:** `backend/app/api/auth.py` exposes `GET /api/auth/me?token=...`; the web frontend and Firefox extension use this form.
|
||||
**Impact:** Query strings can be recorded by reverse-proxy access logs, browser history, monitoring systems, copied URLs, screenshots, and referrer headers. Anyone obtaining a token can use the bearer session until expiry or revocation. The token also identifies the user session because the token is the credential.
|
||||
**Severity:** High, remediated in current worktree
|
||||
**Evidence before remediation:** `backend/app/api/auth.py` exposed `GET /api/auth/me?token=...`, and web/extension callers used the query form.
|
||||
**Current state:** `/api/auth/me` now requires the existing bearer-header dependency. The shared web header, admin session check, Firefox settings page, and tests send `Authorization: Bearer <token>`. A query-string token is rejected with `401`.
|
||||
**Residual impact:** Tokens from old URLs may remain in proxy/browser logs and should be treated as exposed until revoked or rotated.
|
||||
|
||||
**Recommendation:** Make `Authorization: Bearer <token>` the only supported authentication transport for authenticated endpoints. Change the web and extension callers to send the header. If a compatibility transition is required, keep query-token support temporary, disable caching, add a deprecation period, and scrub query parameters from access logs. Consider rotating all existing tokens after migration.
|
||||
**Recommendation:** Rotate existing access tokens after deployment and scrub historical query parameters from logs where possible. Keep the bearer header as the only credential transport.
|
||||
|
||||
**Priority:** High.
|
||||
**Priority:** Completed in code; token rotation and log hygiene remain.
|
||||
|
||||
### SA-003: Sensitive secrets stored in plaintext SQLite
|
||||
|
||||
@@ -196,7 +197,7 @@ These findings are prioritized below. Severity describes the potential security
|
||||
|
||||
- **Authentication transport:** Bearer headers are used by most APIs, but query-string tokens remain a leakage risk. There is no cookie session, which reduces CSRF exposure for current bearer-only API calls. Credentials are now email-based; usernames remain presentation identities.
|
||||
- **Email authentication:** Primary and additional addresses are checked independently; additional addresses are unusable for login until their verification token is consumed. The profile exposes status but not verification secrets.
|
||||
- **Primary email changes:** A replacement remains pending until its verification link is consumed. The existing primary address remains usable during that period, and the same user row retains account permissions and active sessions.
|
||||
- **Primary email selection:** Only an already verified alternative address can be promoted to primary. The same user row retains account permissions and active sessions, and the previous primary is retained as a verified alternative.
|
||||
- **Password policy:** New and reset passwords require at least eight characters. This is better than no policy but should be replaced with a longer passphrase-oriented policy and breached-password screening after a proper password hash migration.
|
||||
- **Email verification:** New administrator-created users cannot log in until verified. The setup-created first administrator is marked verified, which is appropriate for bootstrap but should be protected by the setup controls above.
|
||||
- **Password reset:** Tokens are random, hashed, expiring, single-use, and revoke existing access tokens after reset. Reset-email generation errors are intentionally swallowed to preserve generic login behavior, but this should be paired with server-side monitoring.
|
||||
|
||||
@@ -849,6 +849,12 @@ Change the title of the field “One-time password” to “One-time password (w
|
||||
### Assistant outcome
|
||||
Updated the OTP field label in the English, Spanish, German, French, and Dutch extension locale catalogs to clarify that the code is needed only when OTP is configured.
|
||||
|
||||
### User
|
||||
The plugin settings still show `emailLabel`; make that `email` as the title for the email field.
|
||||
|
||||
### Assistant outcome
|
||||
Added the missing `emailLabel` and `emailPlaceholder` messages to every extension locale. The English field now resolves to `Email` instead of displaying the localization key.
|
||||
|
||||
### User
|
||||
For the new password in the user setting add a validation field to make sure they are the same before submitting.
|
||||
|
||||
@@ -908,3 +914,45 @@ Remove the entire “New primary email address” block; keep only selecting an
|
||||
|
||||
### Assistant outcome
|
||||
Removed the pending primary-email replacement form, client handlers, API routes, service, and obsolete test. The profile now retains only verified alternative promotion, with unverified addresses unable to become primary.
|
||||
|
||||
### User
|
||||
Remove the “New primary email address” functionality and keep only selecting an existing alternative as primary.
|
||||
|
||||
### Assistant outcome
|
||||
Removed the replacement-email workflow entirely. Added cleanup migration 14 for its unused table; verified alternatives remain the only addresses eligible for primary selection.
|
||||
|
||||
### User
|
||||
When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.”
|
||||
|
||||
### Assistant outcome
|
||||
Removed the authenticated-state warning flash by hiding the popup sign-in warning in the initial markup. The popup reveals it only after the session check confirms that no valid bearer session is available.
|
||||
|
||||
### User
|
||||
When the user is signed in, the plugin should not display “Please sign in to use LinkLog.” or the settings link.
|
||||
|
||||
### Assistant outcome
|
||||
Updated popup session detection to validate the stored bearer token through `/api/auth/me` before hiding the sign-in warning and revealing capture fields. Expired or invalid sessions now keep the warning and settings action visible.
|
||||
|
||||
### User
|
||||
The plugin still does not behave as expected and still shows that the user should sign in.
|
||||
|
||||
### Assistant outcome
|
||||
Centralized popup session validation, ensured valid bearer sessions hide the sign-in warning, and cleared stale credentials when the backend rejects a session. The capture form is shown only after successful validation.
|
||||
|
||||
### User
|
||||
In the popup show that the user has logged in.
|
||||
|
||||
### Assistant outcome
|
||||
Added a localized authenticated-session indicator to the Firefox popup, showing the returned username and backend URL after successful session validation. It remains hidden for signed-out or invalid sessions.
|
||||
|
||||
### User
|
||||
The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out.
|
||||
|
||||
### Assistant outcome
|
||||
Made popup authentication states mutually exclusive using the DOM `hidden` property. Authenticated sessions now always hide the sign-in block, while signed-out sessions hide the session text and show only the warning block.
|
||||
|
||||
### User
|
||||
The authenticated session text and sign-in block are still shown together.
|
||||
|
||||
### Assistant outcome
|
||||
Made popup state transitions defensive by synchronizing both `hidden` attributes and CSS classes. Non-authentication tag-loading errors no longer switch the popup to signed-out state; only a rejected session does. Added `display: none !important` guards for both authentication blocks.
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
138. When the user is not logged in then the plugin should just display no form fields but warn the user that they have to log in with a link to settings.
|
||||
139. Remove DEFAULT_BACKEND setting in the plugin.
|
||||
140. Change the title of the field "One-time password" to "One-time password (when configured)".
|
||||
157. The plugin settings still show 'emailLabel'; make that 'email' as title for the email field.
|
||||
141. Remove any leading and trailing spaces when entering fields in the settings page of the plugin.
|
||||
142. Do a full security audit document in what you have done in detail in Security-audit.md
|
||||
143. Address issue 1. and improve password storage
|
||||
@@ -154,6 +155,7 @@
|
||||
145. For the new password in the user setting add a validation field to make sure they are the same before submitting
|
||||
146. Change so that authentication is based on email address rather than username - maintain the username for presentation purposes
|
||||
147. Make sure the web plugin follows same logic
|
||||
154. Implement the recommendation for ### SA-002: Bearer tokens accepted in query strings
|
||||
146. Change so that authentication is based on email address rather than username - maintain the username for presentation purposes
|
||||
147. Make sure the web plugin follows same logic
|
||||
148. In the plugin <span data-i18n="emailLabel">emailLabel</span> should read: <span data-i18n="emailLabel">Email</span>
|
||||
@@ -162,6 +164,13 @@
|
||||
151. Choose primary email from already verified alternative email addresses and increase the number of alternative email addresses allowed to 5.
|
||||
152. Make sure an email can only be selected when it has been validated.
|
||||
153. Remove the entire "New primary email address" block; keep only selecting an existing alternative as primary.
|
||||
155. Remove the “New primary email address” functionality and keep only selecting an existing alternative as primary.
|
||||
158. When the user is logged in the webplugin should not display "Please sign in to use LinkLog."
|
||||
156. When the user is signed in the plugin should not display "Please sign in to use LinkLog." and the link to the settings
|
||||
159. The plugin still does not behave as expected. It still shows that the user should sign in.
|
||||
160. In the popup show that the user has logged in.
|
||||
161. The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out.
|
||||
162. The authenticated session text and sign-in block are still shown together.
|
||||
|
||||
## Future entries
|
||||
|
||||
|
||||
Binary file not shown.
@@ -3,9 +3,10 @@
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.api.dependencies import get_current_user
|
||||
from backend.app.database import get_connection, init_db
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.auth_service import authenticate_user, find_user
|
||||
@@ -96,14 +97,7 @@ def logout(payload: dict):
|
||||
|
||||
|
||||
@router.get('/me')
|
||||
def current_user(token: str):
|
||||
info = validate_token(token)
|
||||
if info is None:
|
||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||
with get_connection() as conn:
|
||||
user = conn.execute('SELECT * FROM users WHERE id = ?', (info['user_id'],)).fetchone()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
def current_user(user: dict = Depends(get_current_user)):
|
||||
return {
|
||||
'id': user['id'],
|
||||
'username': user['username'],
|
||||
|
||||
@@ -223,6 +223,9 @@ CREATE TABLE IF NOT EXISTS pending_primary_email_changes (
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
'''),
|
||||
(14, '''
|
||||
DROP TABLE IF EXISTS pending_primary_email_changes;
|
||||
''')
|
||||
]
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_login_returns_token():
|
||||
assert 'access_token' in payload
|
||||
assert payload['token_type'] == 'bearer'
|
||||
|
||||
admin_session = client.get('/api/auth/me', params={'token': payload['access_token']})
|
||||
admin_session = client.get('/api/auth/me', headers={'Authorization': f"Bearer {payload['access_token']}"})
|
||||
assert admin_session.status_code == 200
|
||||
assert admin_session.json()['is_admin'] is True
|
||||
|
||||
@@ -47,9 +47,10 @@ def test_login_returns_token():
|
||||
'email': 'bob@example.com',
|
||||
'password': 'secret123',
|
||||
}).json()['access_token']
|
||||
user_session = client.get('/api/auth/me', params={'token': user_token})
|
||||
user_session = client.get('/api/auth/me', headers={'Authorization': f"Bearer {user_token}"})
|
||||
assert user_session.status_code == 200
|
||||
assert user_session.json()['is_admin'] is False
|
||||
assert client.get('/api/auth/me', params={'token': payload['access_token']}).status_code == 401
|
||||
|
||||
|
||||
def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login():
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 13
|
||||
assert get_schema_version(connection) == 14
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 13
|
||||
assert get_schema_version(connection) == 14
|
||||
|
||||
connection.close()
|
||||
@@ -190,7 +190,7 @@ async function loadAdminState() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionResponse = await fetch(`/api/auth/me?token=${encodeURIComponent(accessToken)}`);
|
||||
const sessionResponse = await fetch('/api/auth/me', {headers: authHeaders()});
|
||||
if (!sessionResponse.ok) {
|
||||
localStorage.removeItem('linklogAccessToken');
|
||||
showSignedOutState();
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/auth/me?token=${encodeURIComponent(token)}`)
|
||||
fetch('/api/auth/me', {headers: {Authorization: `Bearer ${token}`}})
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('Session expired');
|
||||
return response.json();
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"openSettings": {"message": "Einstellungen öffnen"},
|
||||
"signOut": {"message": "Abmelden"},
|
||||
"backendUrlLabel": {"message": "Backend-URL"},
|
||||
"emailLabel": {"message": "E-Mail"},
|
||||
"emailPlaceholder": {"message": "alice@example.com"},
|
||||
"usernameLabel": {"message": "Benutzername"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Passwort"},
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
"backendUrlLabel": {
|
||||
"message": "Backend URL"
|
||||
},
|
||||
"emailLabel": {
|
||||
"message": "Email"
|
||||
},
|
||||
"emailPlaceholder": {
|
||||
"message": "alice@example.com"
|
||||
},
|
||||
"usernameLabel": {
|
||||
"message": "Username"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"openSettings": {"message": "Abrir configuración"},
|
||||
"signOut": {"message": "Cerrar sesión"},
|
||||
"backendUrlLabel": {"message": "URL del servidor"},
|
||||
"emailLabel": {"message": "Correo electrónico"},
|
||||
"emailPlaceholder": {"message": "alice@example.com"},
|
||||
"usernameLabel": {"message": "Nombre de usuario"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Contraseña"},
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"openSettings": {"message": "Ouvrir les paramètres"},
|
||||
"signOut": {"message": "Se déconnecter"},
|
||||
"backendUrlLabel": {"message": "URL du serveur"},
|
||||
"emailLabel": {"message": "E-mail"},
|
||||
"emailPlaceholder": {"message": "alice@example.com"},
|
||||
"usernameLabel": {"message": "Nom d’utilisateur"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Mot de passe"},
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"openSettings": {"message": "Instellingen openen"},
|
||||
"signOut": {"message": "Uitloggen"},
|
||||
"backendUrlLabel": {"message": "Backend-URL"},
|
||||
"emailLabel": {"message": "E-mail"},
|
||||
"emailPlaceholder": {"message": "alice@example.com"},
|
||||
"usernameLabel": {"message": "Gebruikersnaam"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Wachtwoord"},
|
||||
|
||||
@@ -28,7 +28,8 @@ async function loadSettings() {
|
||||
if (settings.accessToken && settings.backendUrl) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${settings.backendUrl}/api/auth/me?token=${encodeURIComponent(settings.accessToken)}`
|
||||
`${settings.backendUrl}/api/auth/me`,
|
||||
{headers: {Authorization: `Bearer ${settings.accessToken}`}},
|
||||
);
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
|
||||
@@ -149,6 +149,16 @@ button {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.auth-session {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #585b70;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: #94e2d5;
|
||||
font-size: 0.8rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#auth-warning {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -162,6 +172,11 @@ button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#auth-session[hidden],
|
||||
#auth-warning[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.extension-footer {
|
||||
margin-top: 16px;
|
||||
color: #7f849c;
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
</header>
|
||||
|
||||
<div id="status" class="status hidden" aria-live="polite"></div>
|
||||
<div id="auth-warning" class="status error" aria-live="polite">
|
||||
<div id="auth-session" class="auth-session" hidden aria-live="polite"></div>
|
||||
<div id="auth-warning" class="status error" hidden aria-live="polite">
|
||||
<span data-i18n="authRequired">Please sign in to use LinkLog.</span>
|
||||
<button type="button" id="warning-settings" class="secondary" data-i18n="openSettings">Open settings</button>
|
||||
</div>
|
||||
|
||||
+42
-3
@@ -12,6 +12,7 @@ const newTagsInput = document.getElementById('new-tags');
|
||||
const feedLink = document.getElementById('feed-link');
|
||||
const authWarning = document.getElementById('auth-warning');
|
||||
const warningSettingsButton = document.getElementById('warning-settings');
|
||||
const authSession = document.getElementById('auth-session');
|
||||
|
||||
const t = window.linklogI18n;
|
||||
|
||||
@@ -31,13 +32,36 @@ async function getSettings() {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function validateSession(settings) {
|
||||
if (!settings.backendUrl || !settings.accessToken) return null;
|
||||
try {
|
||||
const response = await fetch(`${settings.backendUrl}/api/auth/me`, {
|
||||
headers: {'Authorization': `Bearer ${settings.accessToken}`},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await browser.storage.local.remove(['accessToken', 'tokenType', 'tokenExpiresAt', 'refreshToken']);
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showSignedOutState() {
|
||||
form.classList.add('hidden');
|
||||
authSession.hidden = true;
|
||||
authSession.classList.add('hidden');
|
||||
authWarning.hidden = false;
|
||||
authWarning.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showSignedInState() {
|
||||
function showSignedInState(user, backendUrl) {
|
||||
authWarning.hidden = true;
|
||||
authWarning.classList.add('hidden');
|
||||
authSession.textContent = t('loggedInAt', [user.username || user.email, backendUrl]);
|
||||
authSession.hidden = false;
|
||||
authSession.classList.remove('hidden');
|
||||
form.classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -59,12 +83,20 @@ async function loadExistingTags() {
|
||||
showSignedOutState();
|
||||
return;
|
||||
}
|
||||
showSignedInState();
|
||||
const user = await validateSession(settings);
|
||||
if (!user) {
|
||||
showSignedOutState();
|
||||
return;
|
||||
}
|
||||
showSignedInState(user, settings.backendUrl);
|
||||
const response = await fetch(`${settings.backendUrl}/api/tags`, {
|
||||
headers: {'Authorization': `Bearer ${settings.accessToken}`},
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) showSignedOutState();
|
||||
if (response.status === 401) {
|
||||
showSignedOutState();
|
||||
return;
|
||||
}
|
||||
existingTags.textContent = t('loadTagsFailed');
|
||||
return;
|
||||
}
|
||||
@@ -124,6 +156,13 @@ async function handleSubmit(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await validateSession(settings)) {
|
||||
showSignedOutState();
|
||||
setStatus(t('sessionExpired'), true);
|
||||
browser.runtime.openOptionsPage();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/api/links`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user