Compare commits
5
Commits
6772bf7107
...
27f26e615a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27f26e615a | ||
|
|
c70850b44d | ||
|
|
3e61302bf6 | ||
|
|
94997752b6 | ||
|
|
01a4ed42bd |
@@ -10,6 +10,7 @@ APP_HEALTHCHECK_RETRIES=3
|
||||
LINKLOG_APP_NAME=LinkLog
|
||||
LINKLOG_VERSION=0.1.0
|
||||
LINKLOG_SECRET_KEY=replace-with-a-long-random-secret
|
||||
LINKLOG_DATA_ENCRYPTION_KEY=generate-with-python-cryptography-fernet-key
|
||||
LINKLOG_TOKEN_EXPIRY_DAYS=30
|
||||
LINKLOG_PUBLIC_URL=localhost
|
||||
LINKLOG_SMTP_HOST=
|
||||
|
||||
@@ -110,6 +110,8 @@ Appending a username to the root URL, such as `/alice`, opens that user's public
|
||||
|
||||
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.
|
||||
|
||||
Login failures are throttled per client IP and email. Five failures within 15 minutes trigger a two-minute lockout, including invalid OTP attempts; successful authentication clears the failure counter.
|
||||
|
||||
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`.
|
||||
|
||||
Users can configure a time-based one-time password from the profile page using an authenticator app. The profile displays a provisioning secret and authenticator URI during setup, then requires a current six-digit code to enable or disable OTP. When OTP is enabled, both the web login and Firefox extension settings login require the code. The TOTP secret is never returned by the profile API after setup.
|
||||
@@ -167,6 +169,7 @@ The main configurable values are:
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker |
|
||||
| `LINKLOG_DATA_ENCRYPTION_KEY` | Fernet key for encrypting SMTP, Mastodon, and OTP secrets at rest | required in Docker |
|
||||
| `LINKLOG_DATABASE_PATH` | SQLite file path inside the container | `/app/backend/data/linklog.db` |
|
||||
| `LINKLOG_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` |
|
||||
| `LINKLOG_PUBLIC_URL` | Public hostname used by Traefik and expanded to a callback URL by the backend | `localhost` |
|
||||
@@ -189,6 +192,8 @@ When a verified user enters the wrong password, LinkLog keeps the response gener
|
||||
|
||||
The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself.
|
||||
|
||||
`LINKLOG_DATA_ENCRYPTION_KEY` must be a Fernet key kept outside the database. Generate one with a Python environment that has `cryptography` installed, for example `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`, then store it in `.env` or a protected deployment secret. Losing this key makes encrypted SMTP, Mastodon, and OTP values unrecoverable. Existing plaintext values from earlier versions should be rotated by saving them again after configuring the key.
|
||||
|
||||
Build and start the application:
|
||||
|
||||
```sh
|
||||
@@ -228,6 +233,8 @@ After logging in, open <http://localhost:8000/profile>, enter the Mastodon insta
|
||||
|
||||
LinkLog caches the OAuth application credentials per Mastodon server in the persistent SQLite `app_settings` table, so subsequent connections do not register a new application on every attempt. If the server rate-limits application registration, the profile page reports the upstream `429` response and the user can retry after the server's cooldown.
|
||||
|
||||
Mastodon instances must be HTTPS hostname-only URLs that resolve to public IP addresses. Loopback, private, link-local, multicast, unspecified, reserved, and IPv4-mapped IPv6 destinations are rejected, and outbound redirects are refused.
|
||||
|
||||
Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link.
|
||||
|
||||
## Useful API Calls
|
||||
|
||||
+22
-18
@@ -65,33 +65,37 @@ These findings are prioritized below. Severity describes the potential security
|
||||
|
||||
### SA-003: Sensitive secrets stored in plaintext SQLite
|
||||
|
||||
**Severity:** High
|
||||
**Evidence:** `backend/app/services/email_service.py` stores SMTP settings including `smtp_password` in `app_settings`; `backend/app/services/mastodon_oauth.py` stores Mastodon application secrets and user access tokens in `app_settings` and `user_plugin_config`; `backend/app/api/user_config.py` stores `otp_secret` in the `users` table.
|
||||
**Severity:** High, remediated in current worktree for newly written secrets
|
||||
**Evidence:** `backend/app/services/email_service.py` stores SMTP settings including `smtp_password` in `app_settings`; `backend/app/services/mastodon_oauth.py` stores Mastodon application secrets and user access tokens in `app_settings` and `user_plugin_config`; `backend/app/api/user_config.py` stores `otp_secret` in the `users` table. New writes are encrypted, but legacy plaintext rows require rotation.
|
||||
**Impact:** Read access to the database exposes SMTP credentials, Mastodon posting authority, OAuth client secrets, and TOTP seeds. TOTP seeds cannot be changed by a user who loses the database copy. Database backups therefore contain reusable credentials, not just application data.
|
||||
|
||||
**Recommendation:** Encrypt secrets at rest using an external secret-management system or an application encryption key held outside the database. At minimum, use a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage, restrict file and volume permissions, and document backup key management. Rotate all credentials after a suspected database disclosure. Continue omitting secrets from API responses.
|
||||
**Current state:** Newly stored SMTP passwords, Mastodon OAuth client secrets and access tokens, and TOTP seeds are encrypted with Fernet using `LINKLOG_DATA_ENCRYPTION_KEY`. The key is required in Docker and is not stored in SQLite. The user plugin API no longer returns the Mastodon access token.
|
||||
**Residual impact:** Existing plaintext secrets require a controlled read-and-save rotation after the key is configured. Lost encryption keys make stored secrets unrecoverable.
|
||||
|
||||
**Priority:** High.
|
||||
**Recommendation:** Supply `LINKLOG_DATA_ENCRYPTION_KEY` through a protected secret mechanism, encrypt backups, rotate credentials after suspected disclosure, and migrate existing plaintext values. Continue omitting secrets from API responses.
|
||||
|
||||
**Priority:** Completed for new writes; existing secret migration and key management remain.
|
||||
|
||||
### SA-004: User-controlled Mastodon instance creates SSRF and uncontrolled egress risk
|
||||
|
||||
**Severity:** High
|
||||
**Evidence:** `normalize_instance()` in `backend/app/services/mastodon_oauth.py` and the outbound requests in `backend/app/services/plugin_manager.py` accept an instance supplied by the user and call `urlopen()` against it.
|
||||
**Impact:** A user can potentially configure an internal hostname, loopback address, cloud metadata endpoint, or other private network destination. The backend may send OAuth registration, token, status, or deletion requests to that destination. In addition to SSRF, this bypasses expected network egress policy and may disclose OAuth-related request data to an unintended host.
|
||||
**Severity:** High, remediated in current worktree
|
||||
**Evidence before remediation:** Mastodon instance values were passed to outbound `urlopen()` calls with no DNS/IP-range or redirect controls.
|
||||
**Current state:** `mastodon_security.py` requires hostname-only HTTPS URLs, resolves DNS, rejects loopback, link-local, private, multicast, unspecified, reserved, and IPv4-mapped IPv6 addresses, and uses an opener that refuses redirects. OAuth, posting, and deletion all use these controls.
|
||||
**Residual impact:** DNS and network policy can change after validation; production deployments should still use egress firewalling or a restricted outbound proxy.
|
||||
|
||||
**Recommendation:** Validate Mastodon instances as HTTPS public hostnames. Resolve DNS and reject loopback, link-local, private, multicast, unspecified, and reserved IP ranges, including IPv4-mapped IPv6 addresses. Re-check after redirects and disable or strictly limit redirects. Prefer an outbound proxy with an allow-list and network egress policy. Set explicit URL and response-size limits and use a vetted HTTP client with safe redirect handling. Do not accept arbitrary schemes.
|
||||
**Recommendation:** Keep outbound firewalling or an allow-listed proxy in production, monitor DNS rebinding risk, and maintain response-size/time limits.
|
||||
|
||||
**Priority:** High.
|
||||
**Priority:** Completed in code; network-level egress controls remain.
|
||||
|
||||
### SA-005: Login endpoint lacks rate limiting and lockout
|
||||
|
||||
**Severity:** High
|
||||
**Evidence:** `POST /api/auth/login` in `backend/app/api/auth.py` has no IP, username, or account rate limit. The OTP verification path is also not rate-limited separately.
|
||||
**Impact:** Attackers can perform password guessing and OTP guessing at high speed. Sending a password-reset email after failed authentication can also be abused to generate mail volume and user harassment, even though the response remains generic.
|
||||
**Severity:** High, remediated in current worktree
|
||||
**Evidence before remediation:** `POST /api/auth/login` had no IP, email, or account rate limit, and OTP failures were not throttled separately.
|
||||
**Current state:** Login failures are tracked in SQLite by a SHA-256 key derived from client IP and normalized email. Five failures within 15 minutes cause a two-minute lockout; the endpoint returns `429` with `Retry-After`, and successful password plus OTP authentication clears the counter. Password-reset mail remains generic and should still be rate-limited operationally.
|
||||
|
||||
**Recommendation:** Add a distributed rate limiter keyed by IP and normalized username, with conservative burst limits, exponential backoff, and monitoring. Rate-limit password-reset issuance independently and avoid sending reset mail for every failed attempt. Consider temporary account protection after repeated failures without creating a user-enumeration oracle. Return `Retry-After` where appropriate.
|
||||
**Recommendation:** Use a distributed limiter for multi-instance deployments, add monitoring, and rate-limit password-reset issuance independently. Keep responses generic to avoid account enumeration.
|
||||
|
||||
**Priority:** High.
|
||||
**Priority:** Completed for the single-instance SQLite deployment; distributed limiting and reset-mail controls remain.
|
||||
|
||||
### SA-006: Firefox extension has broad host access and stores bearer tokens in local storage
|
||||
|
||||
@@ -206,7 +210,7 @@ These findings are prioritized below. Severity describes the potential security
|
||||
|
||||
## Data Protection Review
|
||||
|
||||
- SQLite is the primary data store and contains profile data, links, password hashes, tokens, SMTP settings, Mastodon credentials, OAuth state, and OTP secrets.
|
||||
- SQLite is the primary data store and contains profile data, links, password hashes, tokens, SMTP settings, Mastodon credentials, OAuth state, and OTP secrets. New sensitive values are encrypted with the externally supplied Fernet key; existing plaintext values must be rotated.
|
||||
- Database backups must be treated as credential-bearing secrets, encrypted, access-controlled, rotated, and tested for secure deletion.
|
||||
- Avatar files are persistent and publicly served. Validate and re-encode image content before accepting production uploads.
|
||||
- Link URLs and comments are intentionally public feed data. Operators should document that users must not submit secrets in URLs or comments.
|
||||
@@ -228,9 +232,9 @@ Before production exposure:
|
||||
|
||||
- [ ] Replace SHA-256 password hashing with Argon2id, scrypt, or bcrypt and migrate existing accounts.
|
||||
- [ ] Remove query-string token authentication and rotate existing access tokens.
|
||||
- [ ] Encrypt SMTP, Mastodon, OAuth, and OTP secrets at rest; protect encryption keys separately.
|
||||
- [x] Encrypt newly written SMTP, Mastodon, OAuth, and OTP secrets at rest; protect the encryption key separately. Rotate legacy plaintext values.
|
||||
- [ ] Add login, OTP, reset-mail, and setup rate limiting.
|
||||
- [ ] Restrict Mastodon instance validation and outbound network egress.
|
||||
- [x] Validate Mastodon instances as HTTPS public hostnames, reject unsafe DNS/IP ranges, and block redirects. Keep network-level egress controls in production.
|
||||
- [ ] Disable direct host publication of the application port in production.
|
||||
- [ ] Configure HTTPS, HSTS, CSP, Referrer-Policy, frame protections, `nosniff`, and trusted hosts.
|
||||
- [ ] Define a restrictive CORS policy or leave CORS disabled.
|
||||
@@ -249,7 +253,7 @@ Before production exposure:
|
||||
2. Query-token removal and token rotation.
|
||||
3. Secret-at-rest protection and backup controls.
|
||||
4. Login/setup/OTP rate limiting.
|
||||
5. Mastodon SSRF and egress restrictions.
|
||||
5. Production network egress controls for Mastodon.
|
||||
6. Production network and TLS hardening.
|
||||
7. Extension permission and credential-storage review.
|
||||
8. Security headers, trusted hosts, request limits, media validation, recovery codes, and audit logging.
|
||||
|
||||
@@ -849,6 +849,30 @@ 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
|
||||
When a URL with the same title is already in the database, warn the user, allow comment and hashtag changes, and retrigger plugins such as Mastodon reposting.
|
||||
|
||||
### Assistant outcome
|
||||
Added authenticated duplicate detection for matching title and normalized URL. The existing link is updated with the submitted comment and tags, plugins are redispatched, and the popup displays a localized duplicate warning.
|
||||
|
||||
### User
|
||||
When the plugin is activated and the link already exists, show “This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered.”
|
||||
|
||||
### Assistant outcome
|
||||
Added an authenticated duplicate-check request when the popup opens after loading the active page. Existing links now show the requested warning before submission; comment and tag editing plus plugin retriggering remain available.
|
||||
|
||||
### User
|
||||
Display any errors that occur during posting.
|
||||
|
||||
### Assistant outcome
|
||||
Save responses now include sanitized plugin failure details, and the popup displays publishing errors alongside the save confirmation, including Mastodon HTTP or connection failures.
|
||||
|
||||
### User
|
||||
When pressing Save link, display “Link saved to <LinkLog server URL>” and hide all other information. Refresh only when the plugin is opened again.
|
||||
|
||||
### Assistant outcome
|
||||
Added a terminal save state to the popup. After a successful save it displays the localized LinkLog server URL, hides the form, session, warnings, and header information, and performs no refresh until the popup is opened again.
|
||||
|
||||
### User
|
||||
The plugin settings still show `emailLabel`; make that `email` as the title for the email field.
|
||||
|
||||
@@ -921,6 +945,30 @@ Remove the “New primary email address” functionality and keep only selecting
|
||||
### 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
|
||||
Address SA-003 by supplying a dedicated protected encryption key and encrypting sensitive values before SQLite storage.
|
||||
|
||||
### Assistant outcome
|
||||
Added Fernet encryption with required `LINKLOG_DATA_ENCRYPTION_KEY` handling for SMTP, Mastodon OAuth/client and access-token values, TOTP seeds, and recognized administrator plugin secrets. Updated Docker/Compose configuration, tests, README guidance, and the security audit. Legacy plaintext values require rotation after key configuration.
|
||||
|
||||
### User
|
||||
Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
|
||||
|
||||
### Assistant outcome
|
||||
Implemented and validated SA-004 with centralized Mastodon URL checks, DNS resolution and blocked-range detection, IPv4-mapped IPv6 handling, and no-redirect outbound requests for OAuth, posting, and deletion. The rebuilt container suite passed with 42 tests.
|
||||
|
||||
### User
|
||||
Implement SA-005: login endpoint lacks rate limiting and lockout.
|
||||
|
||||
### Assistant outcome
|
||||
Added persistent IP/email login throttling with a five-failure limit in a 15-minute window, two-minute lockout, `Retry-After` responses, OTP failure coverage, and counter reset after successful authentication. Updated documentation and audit status.
|
||||
|
||||
### User
|
||||
Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
|
||||
|
||||
### Assistant outcome
|
||||
Added centralized Mastodon SSRF protection for OAuth, posting, and deletion. Validation requires HTTPS hostname-only instances, resolves and rejects private or reserved destinations, and all outbound requests use a no-redirect opener. Added security tests, documentation, and audit status updates.
|
||||
|
||||
### User
|
||||
When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.”
|
||||
|
||||
@@ -945,6 +993,12 @@ 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
|
||||
Address SA-003 by using a dedicated secret key supplied through a protected environment or secret file, and encrypt sensitive values before SQLite storage.
|
||||
|
||||
### Assistant outcome
|
||||
Added Fernet encryption using the required `LINKLOG_DATA_ENCRYPTION_KEY` for newly stored SMTP passwords, Mastodon OAuth secrets and access tokens, and TOTP seeds. Protected API responses from returning Mastodon tokens, required the key in Docker, documented generation and rotation, and updated the security audit.
|
||||
|
||||
### User
|
||||
The popup still shows the sign-in block even though the authenticated session text is displayed; show the block only when signed out.
|
||||
|
||||
@@ -954,5 +1008,11 @@ Made popup authentication states mutually exclusive using the DOM `hidden` prope
|
||||
### User
|
||||
The authenticated session text and sign-in block are still shown together.
|
||||
|
||||
### User
|
||||
Use the VIBE directory to log interactions
|
||||
|
||||
### Assistant outcome
|
||||
Appended this visible interaction to the project VIBE chat and prompt logs in the repo’s append-only format.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -148,6 +148,10 @@
|
||||
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.
|
||||
163. When a URL with the same title is already in the database, warn the user, allow comment and hashtag changes, and retrigger plugins such as Mastodon reposting.
|
||||
166. Display any errors that occur during posting.
|
||||
164. When the plugin is activated and the link already exists, show "This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered."
|
||||
165. When pressing save link the plugin should display "Link saved to <url of linklog server>" and hide all other information. It should only refresh when the plugin is opened again
|
||||
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
|
||||
@@ -165,12 +169,18 @@
|
||||
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.
|
||||
166. Address SA-003 by using a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage
|
||||
167. Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects
|
||||
168. Implement SA-005: login endpoint lacks rate limiting and lockout
|
||||
167. Address SA-004 by validating Mastodon instances as HTTPS public hostnames, rejecting unsafe DNS/IP ranges including mapped IPv6, and blocking redirects.
|
||||
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.
|
||||
166. Address SA-003 by using a dedicated secret key supplied through a protected environment/secret file, encrypt sensitive values before SQLite storage
|
||||
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.
|
||||
163. Use the VIBE directory to log interactions.
|
||||
|
||||
## Future entries
|
||||
|
||||
|
||||
Binary file not shown.
@@ -20,6 +20,7 @@ from backend.app.services.email_service import (
|
||||
)
|
||||
from backend.app.services.email_verification import create_verification_token
|
||||
from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes
|
||||
from backend.app.services.secret_store import encrypt_secret
|
||||
from backend.app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
@@ -345,6 +346,9 @@ def update_plugin(
|
||||
config = json.loads(current['config']) if current['config'] else {}
|
||||
if payload.config is not None:
|
||||
config.update(payload.config)
|
||||
for secret_name in ('access_token', 'client_secret', 'smtp_password'):
|
||||
if config.get(secret_name):
|
||||
config[secret_name] = encrypt_secret(config[secret_name])
|
||||
|
||||
conn.execute(
|
||||
'''
|
||||
|
||||
+16
-5
@@ -3,7 +3,7 @@
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.api.dependencies import get_current_user
|
||||
@@ -15,7 +15,9 @@ from backend.app.services.email_verification import verify_email
|
||||
from backend.app.services.password_reset import create_reset_token, reset_password
|
||||
from backend.app.services.token_service import issue_token, revoke_token, validate_token
|
||||
from backend.app.services.otp_service import verify_code
|
||||
from backend.app.services.secret_store import decrypt_secret
|
||||
from backend.app.services.email_addresses import verify_user_email_address
|
||||
from backend.app.services.login_throttle import check_login_allowed, clear_login_failures, record_login_failure
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -34,10 +36,17 @@ class PasswordResetRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post('/login')
|
||||
def login(payload: LoginRequest):
|
||||
user = authenticate_user(payload.email.strip(), payload.password)
|
||||
def login(payload: LoginRequest, request: Request):
|
||||
email = payload.email.strip()
|
||||
ip_address = request.client.host if request.client else 'unknown'
|
||||
retry_after = check_login_allowed(ip_address, email)
|
||||
if retry_after is not None:
|
||||
raise HTTPException(status_code=429, detail='Too many failed login attempts. Try again later.', headers={'Retry-After': str(retry_after)})
|
||||
|
||||
user = authenticate_user(email, payload.password)
|
||||
if user is None:
|
||||
reset_user = find_user(payload.email.strip())
|
||||
record_login_failure(ip_address, email)
|
||||
reset_user = find_user(email)
|
||||
if reset_user and reset_user['email_verified'] and smtp_configured():
|
||||
try:
|
||||
token = create_reset_token(reset_user['id'])
|
||||
@@ -48,9 +57,11 @@ def login(payload: LoginRequest):
|
||||
raise HTTPException(status_code=401, detail='Invalid username or password')
|
||||
if not user['email_verified']:
|
||||
raise HTTPException(status_code=403, detail='Email address is not verified')
|
||||
if user['otp_enabled'] and not verify_code(user['otp_secret'], payload.otp):
|
||||
if user['otp_enabled'] and not verify_code(decrypt_secret(user['otp_secret']), payload.otp):
|
||||
record_login_failure(ip_address, email)
|
||||
raise HTTPException(status_code=401, detail='One-time password required or invalid')
|
||||
|
||||
clear_login_failures(ip_address, email)
|
||||
token_data = issue_token(user['id'], user['username'])
|
||||
return {
|
||||
'access_token': token_data['access_token'],
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
from fastapi import APIRouter, Header, HTTPException, Response, status
|
||||
import logging
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.services.link_service import create_link, delete_link, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link
|
||||
from backend.app.services.link_service import create_link, delete_link, find_owned_link_by_title_url, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.plugin_manager import plugin_manager
|
||||
from backend.app.services.token_service import validate_token
|
||||
@@ -35,8 +35,23 @@ def available_tags():
|
||||
return list_tags()
|
||||
|
||||
|
||||
@router.get('/links/check')
|
||||
def check_existing_link(
|
||||
title: str,
|
||||
url: str,
|
||||
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')
|
||||
info = validate_token(authorization.replace('Bearer ', '', 1))
|
||||
if info is None:
|
||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||
record = find_owned_link_by_title_url(info['user_id'], title, url)
|
||||
return {'exists': record is not None}
|
||||
|
||||
|
||||
@router.post('/links', status_code=status.HTTP_201_CREATED)
|
||||
def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header(default=None)):
|
||||
def create_link_endpoint(payload: LinkCreate, response: Response, 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)
|
||||
@@ -45,16 +60,28 @@ def create_link_endpoint(payload: LinkCreate, authorization: str | None = Header
|
||||
raise HTTPException(status_code=401, detail='Token expired or invalid')
|
||||
|
||||
try:
|
||||
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags)
|
||||
record = find_owned_link_by_title_url(info['user_id'], payload.title, payload.url)
|
||||
duplicate = record is not None
|
||||
if duplicate:
|
||||
record = update_link(record['id'], info['user_id'], payload.title, payload.url, payload.comment, payload.tags)
|
||||
else:
|
||||
record = create_link(info['user_id'], payload.title, payload.url, payload.comment, payload.timestamp, payload.tags)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
if duplicate:
|
||||
response.status_code = status.HTTP_200_OK
|
||||
plugin_results = plugin_manager.dispatch({'type': 'link_created', **record})
|
||||
mastodon_result = next((result for result in plugin_results if result.get('plugin') == 'mastodon'), None)
|
||||
if mastodon_result and mastodon_result.get('status') == 'posted':
|
||||
mark_mastodon_posted(record['id'], info['user_id'], mastodon_result.get('post_id'))
|
||||
if any(result.get('status') == 'failed' for result in plugin_results):
|
||||
logger.warning('One or more plugins failed for link_id=%s results=%s', record['id'], plugin_results)
|
||||
return record
|
||||
plugin_errors = [
|
||||
{'plugin': result.get('plugin', 'unknown'), 'reason': result.get('reason', 'Plugin failed')}
|
||||
for result in plugin_results
|
||||
if result.get('status') == 'failed'
|
||||
]
|
||||
return {**record, 'duplicate': duplicate, 'plugin_errors': plugin_errors}
|
||||
|
||||
|
||||
@router.put('/links/{link_id}')
|
||||
|
||||
@@ -15,6 +15,7 @@ from backend.app.services.otp_service import create_secret, provisioning_uri, ve
|
||||
from backend.app.services.email_addresses import add_user_email_address, create_email_verification, list_user_email_addresses
|
||||
from backend.app.services.email_service import send_verification_email, smtp_configured
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -116,7 +117,7 @@ def setup_otp(user: dict = Depends(get_current_user)):
|
||||
raise HTTPException(status_code=409, detail='One-time password is already enabled')
|
||||
secret = create_secret()
|
||||
with get_connection() as conn:
|
||||
conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (secret, user['id']))
|
||||
conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (encrypt_secret(secret), user['id']))
|
||||
conn.commit()
|
||||
return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])}
|
||||
|
||||
@@ -125,7 +126,7 @@ def setup_otp(user: dict = Depends(get_current_user)):
|
||||
def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
|
||||
if payload.action not in {'enable', 'disable'}:
|
||||
raise HTTPException(status_code=422, detail='OTP action must be enable or disable')
|
||||
if not verify_code(user['otp_secret'], payload.code):
|
||||
if not verify_code(decrypt_secret(user['otp_secret']), payload.code):
|
||||
raise HTTPException(status_code=400, detail='Invalid one-time password')
|
||||
with get_connection() as conn:
|
||||
if payload.action == 'enable':
|
||||
@@ -329,6 +330,8 @@ def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_us
|
||||
return {}
|
||||
|
||||
config = json.loads(row['config']) if row['config'] else {}
|
||||
if config.get('access_token'):
|
||||
config.pop('access_token')
|
||||
return config
|
||||
|
||||
|
||||
@@ -346,6 +349,8 @@ def update_user_plugin_config(
|
||||
|
||||
current_config = json.loads(current['config']) if current and current['config'] else {}
|
||||
updates = payload.model_dump(exclude_none=True)
|
||||
if updates.get('access_token'):
|
||||
updates['access_token'] = encrypt_secret(updates['access_token'])
|
||||
merged = {**current_config, **updates}
|
||||
|
||||
if current is None:
|
||||
@@ -368,4 +373,6 @@ def update_user_plugin_config(
|
||||
|
||||
conn.commit()
|
||||
|
||||
return merged
|
||||
public_config = dict(merged)
|
||||
public_config.pop('access_token', None)
|
||||
return public_config
|
||||
|
||||
@@ -24,6 +24,7 @@ class Settings:
|
||||
version: str = os.getenv('LINKLOG_VERSION', '0.1.0')
|
||||
database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}')
|
||||
secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me')
|
||||
data_encryption_key: str = os.getenv('LINKLOG_DATA_ENCRYPTION_KEY', '')
|
||||
token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30'))
|
||||
public_url: str = normalize_public_url(os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000'))
|
||||
smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '')
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
|
||||
|
||||
|
||||
def get_smtp_settings() -> dict:
|
||||
@@ -22,6 +23,7 @@ def get_smtp_settings() -> dict:
|
||||
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone()
|
||||
if row:
|
||||
values.update(json.loads(row['value']))
|
||||
values['smtp_password'] = decrypt_secret(values['smtp_password'])
|
||||
return values
|
||||
|
||||
|
||||
@@ -30,7 +32,7 @@ def save_smtp_settings(values: dict) -> None:
|
||||
conn.execute(
|
||||
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
|
||||
('smtp', json.dumps(values)),
|
||||
('smtp', json.dumps({**values, 'smtp_password': encrypt_secret(values['smtp_password'])})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -109,6 +109,20 @@ def create_link(
|
||||
return record
|
||||
|
||||
|
||||
def find_owned_link_by_title_url(user_id: str, title: str, url: str) -> dict | None:
|
||||
cleaned_url = clean_url(url)
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT * FROM links WHERE user_id = ? AND title = ? AND url = ? ORDER BY created_at DESC LIMIT 1',
|
||||
(user_id, title, cleaned_url),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
record = dict(row)
|
||||
record['tags'] = get_link_tags(conn, record['id'])
|
||||
return record
|
||||
|
||||
|
||||
def list_public_links(username: str | None = None):
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
|
||||
from backend.app.database import get_connection
|
||||
|
||||
MAX_FAILURES = 5
|
||||
FAILURE_WINDOW = timedelta(minutes=15)
|
||||
LOCKOUT_DURATION = timedelta(minutes=2)
|
||||
|
||||
|
||||
def _setting_name(ip_address: str, email: str) -> str:
|
||||
key = sha256(f'{ip_address}\0{email.casefold()}'.encode('utf-8')).hexdigest()
|
||||
return f'login_rate:{key}'
|
||||
|
||||
|
||||
def _read_rate(setting_name: str) -> dict:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(row['value'])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def check_login_allowed(ip_address: str, email: str) -> int | None:
|
||||
rate = _read_rate(_setting_name(ip_address, email))
|
||||
now = datetime.now(timezone.utc)
|
||||
locked_until = datetime.fromisoformat(rate['locked_until']) if rate.get('locked_until') else None
|
||||
if locked_until and locked_until > now:
|
||||
return int((locked_until - now).total_seconds()) + 1
|
||||
return None
|
||||
|
||||
|
||||
def record_login_failure(ip_address: str, email: str) -> None:
|
||||
setting_name = _setting_name(ip_address, email)
|
||||
now = datetime.now(timezone.utc)
|
||||
rate = _read_rate(setting_name)
|
||||
first_failure = datetime.fromisoformat(rate['first_failure']) if rate.get('first_failure') else now
|
||||
if now - first_failure >= FAILURE_WINDOW:
|
||||
rate = {}
|
||||
first_failure = now
|
||||
failures = int(rate.get('failures', 0)) + 1
|
||||
updated = {'failures': failures, 'first_failure': first_failure.isoformat()}
|
||||
if failures >= MAX_FAILURES:
|
||||
updated['locked_until'] = (now + LOCKOUT_DURATION).isoformat()
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
|
||||
(setting_name, json.dumps(updated)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def clear_login_failures(ip_address: str, email: str) -> None:
|
||||
with get_connection() as conn:
|
||||
conn.execute('DELETE FROM app_settings WHERE name = ?', (_setting_name(ip_address, email),))
|
||||
conn.commit()
|
||||
@@ -7,18 +7,17 @@ import json
|
||||
from secrets import token_urlsafe
|
||||
from urllib.parse import urlencode
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
|
||||
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
|
||||
|
||||
|
||||
def normalize_instance(instance: str) -> str:
|
||||
value = instance.strip().rstrip('/')
|
||||
if not value:
|
||||
raise ValueError('Mastodon instance is required')
|
||||
return value if '://' in value else f'https://{value}'
|
||||
return validate_public_instance(instance)
|
||||
|
||||
|
||||
def post_form(url: str, values: dict) -> dict:
|
||||
@@ -28,7 +27,7 @@ def post_form(url: str, values: dict) -> dict:
|
||||
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
method='POST',
|
||||
)
|
||||
with urlopen(request, timeout=10) as response:
|
||||
with open_no_redirect(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
|
||||
|
||||
@@ -39,6 +38,8 @@ def start_authorization(user_id: str, instance: str) -> str:
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
|
||||
app = json.loads(row['value']) if row else None
|
||||
if app and app.get('client_secret'):
|
||||
app['client_secret'] = decrypt_secret(app['client_secret'])
|
||||
if not app:
|
||||
app = post_form(f'{instance}/api/v1/apps', {
|
||||
'client_name': settings.mastodon_client_name,
|
||||
@@ -50,7 +51,7 @@ def start_authorization(user_id: str, instance: str) -> str:
|
||||
conn.execute(
|
||||
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
|
||||
(setting_name, json.dumps(app)),
|
||||
(setting_name, json.dumps({**app, 'client_secret': encrypt_secret(app['client_secret'])})),
|
||||
)
|
||||
conn.commit()
|
||||
state = token_urlsafe(32)
|
||||
@@ -62,7 +63,7 @@ def start_authorization(user_id: str, instance: str) -> str:
|
||||
(id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||
(str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance,
|
||||
app['client_id'], app['client_secret'], redirect_uri, expires_at.isoformat()),
|
||||
app['client_id'], encrypt_secret(app['client_secret']), redirect_uri, expires_at.isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
return f'{instance}/oauth/authorize?' + urlencode({
|
||||
@@ -90,7 +91,7 @@ def finish_authorization(code: str, state: str) -> str:
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'client_id': record['client_id'],
|
||||
'client_secret': record['client_secret'],
|
||||
'client_secret': decrypt_secret(record['client_secret']),
|
||||
'redirect_uri': record['redirect_uri'],
|
||||
})
|
||||
access_token = token.get('access_token')
|
||||
@@ -102,7 +103,7 @@ def finish_authorization(code: str, state: str) -> str:
|
||||
(record['user_id'], 'mastodon'),
|
||||
).fetchone()
|
||||
config = json.loads(current['config']) if current and current['config'] else {}
|
||||
config.update({'instance': record['instance'], 'access_token': access_token})
|
||||
config.update({'instance': record['instance'], 'access_token': encrypt_secret(access_token)})
|
||||
if current:
|
||||
conn.execute(
|
||||
'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
|
||||
class RejectRedirectHandler(HTTPRedirectHandler):
|
||||
def redirect_request(self, request, file, code, msg, headers, newurl):
|
||||
raise HTTPError(request.full_url, code, 'Redirects are not allowed', headers, None)
|
||||
|
||||
|
||||
NO_REDIRECT_OPENER = build_opener(RejectRedirectHandler)
|
||||
|
||||
|
||||
def _is_blocked_address(address: str) -> bool:
|
||||
parsed = ipaddress.ip_address(address)
|
||||
mapped = parsed.ipv4_mapped if isinstance(parsed, ipaddress.IPv6Address) else None
|
||||
candidates = (parsed, mapped) if mapped else (parsed,)
|
||||
return any(
|
||||
candidate.is_loopback
|
||||
or candidate.is_link_local
|
||||
or candidate.is_private
|
||||
or candidate.is_multicast
|
||||
or candidate.is_unspecified
|
||||
or candidate.is_reserved
|
||||
for candidate in candidates
|
||||
)
|
||||
|
||||
|
||||
def validate_public_instance(instance: str) -> str:
|
||||
value = instance.strip().rstrip('/')
|
||||
if not value:
|
||||
raise ValueError('Mastodon instance is required')
|
||||
if '://' not in value:
|
||||
value = f'https://{value}'
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme.lower() != 'https' or not parsed.hostname:
|
||||
raise ValueError('Mastodon instance must be an HTTPS public hostname')
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
|
||||
raise ValueError('Mastodon instance must be a hostname-only HTTPS URL')
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError('Mastodon instance has an invalid port') from error
|
||||
if port not in (None, 443):
|
||||
raise ValueError('Mastodon instance must use HTTPS port 443')
|
||||
hostname = parsed.hostname.rstrip('.').lower()
|
||||
try:
|
||||
addresses = {result[4][0] for result in socket.getaddrinfo(hostname, port or 443, type=socket.SOCK_STREAM)}
|
||||
except socket.gaierror as error:
|
||||
raise ValueError('Mastodon instance hostname could not be resolved') from error
|
||||
if not addresses or any(_is_blocked_address(address) for address in addresses):
|
||||
raise ValueError('Mastodon instance must resolve only to public IP addresses')
|
||||
return f'https://{hostname}'
|
||||
|
||||
|
||||
def open_no_redirect(request: Request, timeout: int = 10):
|
||||
return NO_REDIRECT_OPENER.open(request, timeout=timeout)
|
||||
@@ -5,9 +5,11 @@ import json
|
||||
import logging
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.request import Request
|
||||
|
||||
from backend.app.plugins.base import BasePlugin
|
||||
from backend.app.services.secret_store import decrypt_secret
|
||||
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
|
||||
|
||||
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,10 +49,12 @@ class MastodonPlugin(BasePlugin):
|
||||
).fetchone()
|
||||
if row and row['config']:
|
||||
config.update(json.loads(row['config']))
|
||||
config['access_token'] = decrypt_secret(config.get('access_token', ''))
|
||||
|
||||
instance = str(config.get('instance', '')).strip().rstrip('/')
|
||||
if instance and '://' not in instance:
|
||||
instance = f'https://{instance}'
|
||||
try:
|
||||
instance = validate_public_instance(str(config.get('instance', '')))
|
||||
except ValueError as error:
|
||||
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
|
||||
access_token = str(config.get('access_token', '')).strip()
|
||||
if not instance or not access_token:
|
||||
logger.debug(
|
||||
@@ -90,7 +94,7 @@ class MastodonPlugin(BasePlugin):
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
with urlopen(request, timeout=5) as response:
|
||||
with open_no_redirect(request, timeout=5) as response:
|
||||
response_body = response.read().decode('utf-8')
|
||||
logger.debug(
|
||||
'Mastodon post response: endpoint=%s status=%s body_length=%d',
|
||||
@@ -135,10 +139,12 @@ class MastodonPlugin(BasePlugin):
|
||||
).fetchone()
|
||||
if row and row['config']:
|
||||
config.update(json.loads(row['config']))
|
||||
config['access_token'] = decrypt_secret(config.get('access_token', ''))
|
||||
|
||||
instance = str(config.get('instance', '')).strip().rstrip('/')
|
||||
if instance and '://' not in instance:
|
||||
instance = f'https://{instance}'
|
||||
try:
|
||||
instance = validate_public_instance(str(config.get('instance', '')))
|
||||
except ValueError as error:
|
||||
return {'status': 'failed', 'plugin': self.name, 'reason': str(error)}
|
||||
access_token = str(config.get('access_token', '')).strip()
|
||||
post_ids = event.get('mastodon_post_ids') or []
|
||||
if not post_ids and event.get('mastodon_post_id'):
|
||||
@@ -153,7 +159,7 @@ class MastodonPlugin(BasePlugin):
|
||||
headers={'Authorization': f'Bearer {access_token}', 'User-Agent': 'LinkLog/1.0'},
|
||||
method='DELETE',
|
||||
)
|
||||
with urlopen(request, timeout=5) as response:
|
||||
with open_no_redirect(request, timeout=5) as response:
|
||||
response.read()
|
||||
return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)}
|
||||
except HTTPError as error:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from backend.app.core.config import settings
|
||||
|
||||
|
||||
def _cipher() -> Fernet:
|
||||
if not settings.data_encryption_key:
|
||||
raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY is required to access encrypted secrets')
|
||||
try:
|
||||
return Fernet(settings.data_encryption_key.encode('ascii'))
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise RuntimeError('LINKLOG_DATA_ENCRYPTION_KEY must be a valid Fernet key') from error
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
if not value:
|
||||
return value
|
||||
if value.startswith('enc:v1:'):
|
||||
return value
|
||||
return 'enc:v1:' + _cipher().encrypt(value.encode('utf-8')).decode('ascii')
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
if not value or not value.startswith('enc:v1:'):
|
||||
return value
|
||||
try:
|
||||
return _cipher().decrypt(value[7:].encode('ascii')).decode('utf-8')
|
||||
except (InvalidToken, UnicodeEncodeError) as error:
|
||||
raise RuntimeError('Encrypted secret cannot be decrypted with LINKLOG_DATA_ENCRYPTION_KEY') from error
|
||||
@@ -6,3 +6,4 @@ python-multipart==0.0.20
|
||||
pytest==9.1.1
|
||||
httpx==0.28.1
|
||||
httpx2==2.12.0
|
||||
cryptography==46.0.3
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
|
||||
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
|
||||
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
|
||||
os.environ['LINKLOG_DATA_ENCRYPTION_KEY'] = 'L5M4sQYVjD1N7pT2Xk8R0aBcDeFgHiJkLmNoPqRsTuV='
|
||||
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
|
||||
+81
-13
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
|
||||
from backend.app.main import app
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.email_service import get_smtp_settings
|
||||
from backend.app.services.login_throttle import clear_login_failures
|
||||
from backend.app.services.password_reset import create_reset_token
|
||||
from backend.app.services.token_service import issue_token
|
||||
|
||||
@@ -53,6 +54,20 @@ def test_login_returns_token():
|
||||
assert client.get('/api/auth/me', params={'token': payload['access_token']}).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):
|
||||
response = client.post('/api/auth/login', json={'email': email, 'password': 'wrong-password'})
|
||||
assert response.status_code == 401, attempt
|
||||
locked = client.post('/api/auth/login', json={'email': email, 'password': 'wrong-password'})
|
||||
assert locked.status_code == 429
|
||||
assert int(locked.headers['Retry-After']) > 0
|
||||
|
||||
clear_login_failures('testclient', email)
|
||||
valid = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'})
|
||||
assert valid.status_code == 200
|
||||
|
||||
|
||||
def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login():
|
||||
from hashlib import sha256
|
||||
from backend.app.database import hash_password
|
||||
@@ -415,6 +430,57 @@ def test_submit_link_stores_cleaned_url_and_public_feed():
|
||||
assert 'alice' in users_response.json()
|
||||
|
||||
|
||||
def test_duplicate_link_updates_comment_tags_and_retriggers_plugins():
|
||||
headers = login_headers()
|
||||
payload = {
|
||||
'title': 'Duplicate candidate',
|
||||
'url': 'https://example.com/duplicate?utm_source=campaign',
|
||||
'comment': 'first comment',
|
||||
'tags': ['#First'],
|
||||
}
|
||||
first = client.post('/api/links', headers=headers, json=payload)
|
||||
assert first.status_code == 201
|
||||
|
||||
with patch('backend.app.api.links.plugin_manager.dispatch', return_value=[]) as dispatch:
|
||||
duplicate = client.post('/api/links', headers=headers, json={
|
||||
**payload,
|
||||
'comment': 'updated comment',
|
||||
'tags': ['#Second'],
|
||||
})
|
||||
assert duplicate.status_code == 200
|
||||
assert duplicate.json()['duplicate'] is True
|
||||
assert duplicate.json()['id'] == first.json()['id']
|
||||
dispatch.assert_called_once()
|
||||
assert dispatch.call_args.args[0]['comment'] == 'updated comment'
|
||||
assert dispatch.call_args.args[0]['tags'] == ['#Second']
|
||||
feed_item = next(item for item in client.get('/api/public/feed').json() if item['id'] == first.json()['id'])
|
||||
assert feed_item['comment'] == 'updated comment'
|
||||
assert feed_item['tags'] == ['#Second']
|
||||
|
||||
|
||||
def test_duplicate_link_check_is_authenticated_and_detects_existing_entry():
|
||||
headers = login_headers()
|
||||
payload = {'title': 'Check candidate', 'url': 'https://example.com/check-candidate'}
|
||||
assert client.get('/api/links/check', params=payload).status_code == 401
|
||||
created = client.post('/api/links', headers=headers, json=payload)
|
||||
assert created.status_code == 201
|
||||
check = client.get('/api/links/check', headers=headers, params=payload)
|
||||
assert check.status_code == 200
|
||||
assert check.json()['exists'] is True
|
||||
|
||||
|
||||
def test_link_save_reports_plugin_posting_errors():
|
||||
with patch('backend.app.api.links.plugin_manager.dispatch', return_value=[
|
||||
{'status': 'failed', 'plugin': 'mastodon', 'reason': 'HTTP 503: unavailable'},
|
||||
]):
|
||||
response = client.post('/api/links', headers=login_headers(), json={
|
||||
'title': 'Plugin error report',
|
||||
'url': 'https://example.com/plugin-error-report',
|
||||
})
|
||||
assert response.status_code == 201
|
||||
assert response.json()['plugin_errors'] == [{'plugin': 'mastodon', 'reason': 'HTTP 503: unavailable'}]
|
||||
|
||||
|
||||
def test_links_support_tags_and_tag_filtering():
|
||||
headers = login_headers()
|
||||
response = client.post('/api/links', headers=headers, json={
|
||||
@@ -597,19 +663,20 @@ def test_link_submission_posts_to_enabled_mastodon_plugin():
|
||||
try:
|
||||
headers = login_headers()
|
||||
base_url = f'http://127.0.0.1:{server.server_port}'
|
||||
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
|
||||
'instance': base_url,
|
||||
'access_token': 'test-token',
|
||||
'post_prefix': 'From my #LinkLog: ',
|
||||
}).status_code == 200
|
||||
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200
|
||||
with patch('backend.app.services.plugin_manager.validate_public_instance', return_value=base_url):
|
||||
assert client.put('/api/user/plugins/mastodon', headers=headers, json={
|
||||
'instance': base_url,
|
||||
'access_token': 'test-token',
|
||||
'post_prefix': 'From my #LinkLog: ',
|
||||
}).status_code == 200
|
||||
assert client.put('/api/admin/plugins/mastodon', headers=headers, json={'enabled': True}).status_code == 200
|
||||
|
||||
response = client.post('/api/links', headers=headers, json={
|
||||
'title': 'A useful page',
|
||||
'url': 'https://example.com/useful',
|
||||
'comment': 'Worth sharing',
|
||||
'tags': ['#python', '#web'],
|
||||
})
|
||||
response = client.post('/api/links', headers=headers, json={
|
||||
'title': 'A useful page',
|
||||
'url': 'https://example.com/useful',
|
||||
'comment': 'Worth sharing',
|
||||
'tags': ['#python', '#web'],
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert received['path'] == '/api/v1/statuses'
|
||||
@@ -632,7 +699,8 @@ def test_mastodon_post_without_title_omits_source_line():
|
||||
plugin = MastodonPlugin()
|
||||
plugin.initialize({'instance': 'https://mastodon.example', 'access_token': 'test-token'})
|
||||
|
||||
with patch('backend.app.services.plugin_manager.urlopen', return_value=response) as open_url:
|
||||
with patch('backend.app.services.plugin_manager.open_no_redirect', return_value=response) as open_url, \
|
||||
patch('backend.app.services.plugin_manager.validate_public_instance', return_value='https://mastodon.example'):
|
||||
result = plugin.handle_event({
|
||||
'url': 'https://example.com/useful',
|
||||
'title': '',
|
||||
|
||||
@@ -40,3 +40,18 @@ def test_send_test_email_uses_configured_recipient(monkeypatch):
|
||||
message = smtp.send_message.call_args.args[0]
|
||||
assert message['To'] == 'admin@example.com'
|
||||
assert message['Subject'] == 'LinkLog SMTP test'
|
||||
|
||||
|
||||
def test_smtp_password_is_encrypted_at_rest():
|
||||
from backend.app.services.email_service import get_smtp_settings, save_smtp_settings
|
||||
from backend.app.database import get_connection
|
||||
|
||||
values = {
|
||||
'smtp_host': 'smtp.example.com', 'smtp_port': 587, 'smtp_username': 'mailer',
|
||||
'smtp_password': 'secret', 'smtp_from': 'LinkLog <no-reply@example.com>', 'smtp_use_tls': True,
|
||||
}
|
||||
save_smtp_settings(values)
|
||||
with get_connection() as conn:
|
||||
stored = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone()['value']
|
||||
assert 'secret' not in stored
|
||||
assert get_smtp_settings()['smtp_password'] == 'secret'
|
||||
@@ -0,0 +1,34 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.mastodon_security import open_no_redirect, validate_public_instance
|
||||
|
||||
|
||||
@pytest.mark.parametrize('instance', [
|
||||
'http://mastodon.example',
|
||||
'https://127.0.0.1',
|
||||
'https://[::ffff:127.0.0.1]',
|
||||
'https://user:password@mastodon.example',
|
||||
])
|
||||
def test_mastodon_instance_rejects_unsafe_urls(instance):
|
||||
with pytest.raises(ValueError):
|
||||
validate_public_instance(instance)
|
||||
|
||||
|
||||
def test_mastodon_instance_rejects_private_dns_result():
|
||||
with patch('backend.app.services.mastodon_security.socket.getaddrinfo', return_value=[(2, 1, 6, '', ('10.0.0.5', 443))]):
|
||||
with pytest.raises(ValueError, match='public IP'):
|
||||
validate_public_instance('https://mastodon.example')
|
||||
|
||||
|
||||
def test_mastodon_outbound_redirects_are_rejected():
|
||||
request = Request('https://mastodon.example/api/v1/statuses')
|
||||
with patch('backend.app.services.mastodon_security.NO_REDIRECT_OPENER.open', side_effect=HTTPError(request.full_url, 302, 'Redirects are not allowed', {}, None)):
|
||||
with pytest.raises(HTTPError, match='Redirects are not allowed'):
|
||||
open_no_redirect(request)
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
LINKLOG_APP_NAME: ${LINKLOG_APP_NAME:-LinkLog}
|
||||
LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db}
|
||||
LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env}
|
||||
LINKLOG_DATA_ENCRYPTION_KEY: ${LINKLOG_DATA_ENCRYPTION_KEY:?Set LINKLOG_DATA_ENCRYPTION_KEY in .env}
|
||||
LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-localhost}
|
||||
LINKLOG_LOG_LEVEL: ${LINKLOG_LOG_LEVEL:-INFO}
|
||||
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
"configureAndLogIn": {"message": "Konfiguriere die Backend-URL und melde dich zuerst an."},
|
||||
"sessionExpired": {"message": "Sitzung abgelaufen. Authentifiziere dich in den Einstellungen erneut."},
|
||||
"submissionFailed": {"message": "Senden fehlgeschlagen"},
|
||||
"linkSaved": {"message": "Link erfolgreich gespeichert"},
|
||||
"linkSaved": {"message": "Link auf $URL$ gespeichert.", "placeholders": {"url": {"content": "$1"}}},
|
||||
"linkAlreadyExists": {"message": "Dieser Link existiert bereits. Kommentar und Tags wurden aktualisiert und die Veröffentlichung erneut ausgelöst."},
|
||||
"duplicateLinkWarning": {"message": "Dieser Link existiert bereits. Kommentar und Tags können aktualisiert werden; beim Absenden wird die Veröffentlichung erneut ausgelöst."},
|
||||
"publishingErrors": {"message": "Fehler bei der Veröffentlichung: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
|
||||
"submissionFailedConnection": {"message": "Senden fehlgeschlagen. Überprüfe die Verbindung zum Backend."},
|
||||
"loggedInAt": {"message": "$USERNAME$ ist bei $BACKEND$ angemeldet", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
|
||||
"fillAllFields": {"message": "Fülle alle Felder aus"},
|
||||
|
||||
@@ -123,7 +123,26 @@
|
||||
"message": "Submission failed"
|
||||
},
|
||||
"linkSaved": {
|
||||
"message": "Link saved successfully"
|
||||
"message": "Link saved to $URL$.",
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"content": "$1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linkAlreadyExists": {
|
||||
"message": "This link already exists. Comment and tags were updated, and publishing was retriggered."
|
||||
},
|
||||
"duplicateLinkWarning": {
|
||||
"message": "This link already exists. Comment and tags can be updated, and by submitting publishing will be retriggered."
|
||||
},
|
||||
"publishingErrors": {
|
||||
"message": "Publishing errors: $ERRORS$",
|
||||
"placeholders": {
|
||||
"errors": {
|
||||
"content": "$1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submissionFailedConnection": {
|
||||
"message": "Submission failed. Check your backend connection."
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
"configureAndLogIn": {"message": "Configura la URL del servidor e inicia sesión primero."},
|
||||
"sessionExpired": {"message": "La sesión ha caducado. Vuelve a autenticarte en la configuración."},
|
||||
"submissionFailed": {"message": "Error al enviar"},
|
||||
"linkSaved": {"message": "Enlace guardado correctamente"},
|
||||
"linkSaved": {"message": "Enlace guardado en $URL$.", "placeholders": {"url": {"content": "$1"}}},
|
||||
"linkAlreadyExists": {"message": "Este enlace ya existe. Se actualizaron el comentario y las etiquetas, y se volvió a activar la publicación."},
|
||||
"duplicateLinkWarning": {"message": "Este enlace ya existe. Puedes actualizar el comentario y las etiquetas; al enviarlo se volverá a activar la publicación."},
|
||||
"publishingErrors": {"message": "Errores de publicación: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
|
||||
"submissionFailedConnection": {"message": "Error al enviar. Comprueba la conexión con el servidor."},
|
||||
"loggedInAt": {"message": "$USERNAME$ ha iniciado sesión en $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
|
||||
"fillAllFields": {"message": "Rellena todos los campos"},
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
"configureAndLogIn": {"message": "Configurez l’URL du serveur et connectez-vous d’abord."},
|
||||
"sessionExpired": {"message": "Session expirée. Reconnectez-vous dans les paramètres."},
|
||||
"submissionFailed": {"message": "Échec de l’envoi"},
|
||||
"linkSaved": {"message": "Lien enregistré"},
|
||||
"linkSaved": {"message": "Lien enregistré sur $URL$.", "placeholders": {"url": {"content": "$1"}}},
|
||||
"linkAlreadyExists": {"message": "Ce lien existe déjà. Le commentaire et les étiquettes ont été mis à jour et la publication a été relancée."},
|
||||
"duplicateLinkWarning": {"message": "Ce lien existe déjà. Le commentaire et les étiquettes peuvent être mis à jour ; l’envoi relancera la publication."},
|
||||
"publishingErrors": {"message": "Erreurs de publication : $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
|
||||
"submissionFailedConnection": {"message": "Échec de l’envoi. Vérifiez la connexion au serveur."},
|
||||
"loggedInAt": {"message": "$USERNAME$ est connecté à $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
|
||||
"fillAllFields": {"message": "Veuillez remplir tous les champs"},
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
"configureAndLogIn": {"message": "Configureer de backend-URL en log eerst in."},
|
||||
"sessionExpired": {"message": "Sessie verlopen. Verifieer opnieuw in de instellingen."},
|
||||
"submissionFailed": {"message": "Verzenden mislukt"},
|
||||
"linkSaved": {"message": "Koppeling opgeslagen"},
|
||||
"linkSaved": {"message": "Koppeling opgeslagen op $URL$.", "placeholders": {"url": {"content": "$1"}}},
|
||||
"linkAlreadyExists": {"message": "Deze koppeling bestaat al. De opmerking en tags zijn bijgewerkt en publiceren is opnieuw gestart."},
|
||||
"duplicateLinkWarning": {"message": "Deze koppeling bestaat al. De opmerking en tags kunnen worden bijgewerkt; na verzenden wordt publiceren opnieuw gestart."},
|
||||
"publishingErrors": {"message": "Publicatiefouten: $ERRORS$", "placeholders": {"errors": {"content": "$1"}}},
|
||||
"submissionFailedConnection": {"message": "Verzenden mislukt. Controleer de verbinding met de backend."},
|
||||
"loggedInAt": {"message": "$USERNAME$ is ingelogd op $BACKEND$", "placeholders": {"username": {"content": "$1"}, "backend": {"content": "$2"}}},
|
||||
"fillAllFields": {"message": "Vul alle velden in"},
|
||||
|
||||
+40
-2
@@ -65,6 +65,19 @@ function showSignedInState(user, backendUrl) {
|
||||
form.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showSavedState(message) {
|
||||
document.querySelector('header').classList.add('hidden');
|
||||
document.querySelector('.extension-footer').classList.add('hidden');
|
||||
authSession.hidden = true;
|
||||
authSession.classList.add('hidden');
|
||||
authWarning.hidden = true;
|
||||
authWarning.classList.add('hidden');
|
||||
form.classList.add('hidden');
|
||||
statusEl.textContent = message;
|
||||
statusEl.classList.remove('hidden', 'error');
|
||||
statusEl.classList.add('success');
|
||||
}
|
||||
|
||||
async function updateFeedLink() {
|
||||
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
|
||||
if (!settings.backendUrl || !settings.username || !settings.accessToken) return;
|
||||
@@ -142,6 +155,24 @@ async function populateCurrentTab() {
|
||||
urlInput.value = tab.url || '';
|
||||
}
|
||||
|
||||
async function checkExistingLink() {
|
||||
const settings = await getSettings();
|
||||
if (!settings.backendUrl || !settings.accessToken || !titleInput.value || !urlInput.value) return;
|
||||
try {
|
||||
const response = await fetch(`${settings.backendUrl}/api/links/check?${new URLSearchParams({
|
||||
title: titleInput.value,
|
||||
url: removeKnownTrackingParams(urlInput.value),
|
||||
})}`, {
|
||||
headers: {'Authorization': `Bearer ${settings.accessToken}`},
|
||||
});
|
||||
if (response.ok && (await response.json()).exists) {
|
||||
setStatus(t('duplicateLinkWarning'), true);
|
||||
}
|
||||
} catch (error) {
|
||||
// Duplicate checking is advisory; submission remains available.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setStatus(t('submitting'), false);
|
||||
@@ -190,7 +221,14 @@ async function handleSubmit(event) {
|
||||
throw new Error(t('submissionFailed'));
|
||||
}
|
||||
|
||||
setStatus(t('linkSaved'));
|
||||
const result = await response.json();
|
||||
const saveMessage = result.duplicate ? t('linkAlreadyExists') : t('linkSaved', backendUrl);
|
||||
if (result.plugin_errors?.length) {
|
||||
const errors = result.plugin_errors.map((error) => `${error.plugin}: ${error.reason}`).join(' ');
|
||||
showSavedState(`${saveMessage} ${t('publishingErrors', errors)}`);
|
||||
return;
|
||||
}
|
||||
showSavedState(saveMessage);
|
||||
} catch (error) {
|
||||
setStatus(t('submissionFailedConnection'), true);
|
||||
}
|
||||
@@ -199,6 +237,6 @@ async function handleSubmit(event) {
|
||||
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
|
||||
warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
populateCurrentTab();
|
||||
populateCurrentTab().then(checkExistingLink);
|
||||
loadExistingTags();
|
||||
updateFeedLink();
|
||||
|
||||
Reference in New Issue
Block a user