Addressed SA-3 by encrypting the sqlite content with a .env secret

This commit is contained in:
2026-08-26 16:39:53 +02:00
parent 94997752b6
commit 3e61302bf6
17 changed files with 108 additions and 16 deletions
+1
View File
@@ -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=
+3
View File
@@ -167,6 +167,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 +190,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
+9 -6
View File
@@ -65,13 +65,16 @@ 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
@@ -206,7 +209,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,7 +231,7 @@ 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.
- [ ] Disable direct host publication of the application port in production.
+12
View File
@@ -945,6 +945,12 @@ 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
When the user is logged in, the web plugin should not display “Please sign in to use LinkLog.”
@@ -969,6 +975,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.
+2
View File
@@ -169,10 +169,12 @@
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
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.
+4
View File
@@ -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(
'''
+2 -1
View File
@@ -15,6 +15,7 @@ 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
router = APIRouter()
@@ -48,7 +49,7 @@ 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):
raise HTTPException(status_code=401, detail='One-time password required or invalid')
token_data = issue_token(user['id'], user['username'])
+10 -3
View File
@@ -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
+1
View File
@@ -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', '')
+3 -1
View File
@@ -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()
+7 -4
View File
@@ -12,6 +12,7 @@ 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
def normalize_instance(instance: str) -> str:
@@ -39,6 +40,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 +53,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 +65,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 +93,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 +105,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 = ?',
+3
View File
@@ -8,6 +8,7 @@ from urllib.parse import urlencode
from urllib.request import Request, urlopen
from backend.app.plugins.base import BasePlugin
from backend.app.services.secret_store import decrypt_secret
DEFAULT_POST_PREFIX = 'From my #LinkLog: '
logger = logging.getLogger(__name__)
@@ -47,6 +48,7 @@ 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:
@@ -135,6 +137,7 @@ 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:
+32
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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)
+16 -1
View File
@@ -39,4 +39,19 @@ 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'
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'
+1
View File
@@ -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}