fix: security hardening across platform

- Disable open /api/auth/register endpoint (gateway)
- Require gateway session auth on Immich and Karakeep hooks proxies
- Replace SHA-256 with bcrypt in fitness service (auth + seed)
- Remove hardcoded Telegram user IDs from fitness seed
- Add Secure flag to session cookie
- Add domain allowlist and content-type validation to image proxy
- Strengthen .gitignore (env variants, runtime data, test artifacts)
This commit is contained in:
Yusuf Suleman
2026-03-29 08:25:50 -05:00
parent d1801540ae
commit 6bd23e7e8b
8 changed files with 111 additions and 27 deletions

39
.gitignore vendored
View File

@@ -1,18 +1,39 @@
node_modules/
.svelte-kit/
build/
# Secrets and local config
.env
.env.*
!.env.example
services/**/.env
services/**/.env.*
# Dependencies
node_modules/
frontend-v2/node_modules/
# Build artifacts
.svelte-kit/
frontend-v2/.svelte-kit/
build/
frontend-v2/build/
__pycache__/
*.pyc
# Runtime data
*.db
*.db-journal
*.db-wal
*.db-shm
data/
__pycache__/
*.pyc
.DS_Store
**/data/*.db
**/data/*.json
services/fitness/data/
services/trips/data/
gateway/data/
frontend-v2/.svelte-kit/
frontend-v2/build/
frontend-v2/node_modules/
# OS
.DS_Store
# Media
*.png
# Test artifacts
test-results/

View File

@@ -8,8 +8,26 @@ const karakeepUrl = env.KARAKEEP_URL || '';
const karakeepApiKey = env.KARAKEEP_API_KEY || '';
export const handle: Handle = async ({ event, resolve }) => {
async function isAuthenticated(request: Request): Promise<boolean> {
const cookie = request.headers.get('cookie') || '';
if (!cookie.includes('platform_session=')) return false;
try {
const res = await fetch(`${gatewayUrl}/api/auth/me`, { headers: { cookie } });
if (!res.ok) return false;
const data = await res.json();
return data.authenticated === true;
} catch {
return false;
}
}
// ── Immich API proxy (shared across all pages) ──
if (event.url.pathname.startsWith('/api/immich/') && immichUrl && immichApiKey) {
if (!await isAuthenticated(event.request)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' }
});
}
const immichPath = event.url.pathname.replace('/api/immich', '/api');
// Thumbnail/original image proxy — cache-friendly binary response
@@ -78,6 +96,11 @@ export const handle: Handle = async ({ event, resolve }) => {
// ── Karakeep API proxy (server-to-server) ──
if (event.url.pathname.startsWith('/api/karakeep/') && karakeepUrl && karakeepApiKey) {
if (!await isAuthenticated(event.request)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' }
});
}
const action = event.url.pathname.split('/').pop(); // 'save' or 'delete'
try {
const body = await event.request.json();
@@ -125,6 +148,9 @@ export const handle: Handle = async ({ event, resolve }) => {
// Legacy trips-specific Immich thumbnail (keep for backward compat)
if (event.url.pathname.startsWith('/api/trips/immich/thumb/') && immichUrl && immichApiKey) {
if (!await isAuthenticated(event.request)) {
return new Response('', { status: 401 });
}
const assetId = event.url.pathname.split('/').pop();
try {
const response = await fetch(`${immichUrl}/api/assets/${assetId}/thumbnail`, {

View File

@@ -1,5 +1,5 @@
"""
Platform Gateway — Image proxy (bypass hotlink protection).
Platform Gateway — Image proxy with domain allowlist.
"""
import urllib.request
@@ -7,24 +7,65 @@ import urllib.parse
from config import _ssl_ctx
ALLOWED_IMAGE_DOMAINS = {
"i.redd.it",
"preview.redd.it",
"external-preview.redd.it",
"i.imgur.com",
"imgur.com",
"images-na.ssl-images-amazon.com",
"m.media-amazon.com",
"cdn.discordapp.com",
"media.discordapp.net",
"pbs.twimg.com",
"abs.twimg.com",
"upload.wikimedia.org",
"images.unsplash.com",
}
def handle_image_proxy(handler):
"""Proxy external images to bypass hotlink protection (e.g. Reddit)."""
"""Proxy external images to bypass hotlink protection."""
qs = urllib.parse.urlparse(handler.path).query
params = urllib.parse.parse_qs(qs)
url = params.get("url", [None])[0]
if not url:
handler._send_json({"error": "Missing url parameter"}, 400)
return
# Validate URL scheme and domain allowlist
try:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
handler._send_json({"error": "Invalid URL scheme"}, 400)
return
hostname = parsed.hostname or ""
if hostname not in ALLOWED_IMAGE_DOMAINS:
allowed = any(
hostname == d or hostname.endswith(f".{d}")
for d in ALLOWED_IMAGE_DOMAINS
)
if not allowed:
print(f"[ImageProxy] Blocked request for domain: {hostname}")
handler._send_json({"error": "Domain not allowed"}, 403)
return
except Exception:
handler._send_json({"error": "Invalid URL"}, 400)
return
try:
req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; PlatformProxy/1.0)",
"Accept": "image/*,*/*",
"Referer": urllib.parse.urlparse(url).scheme + "://" + urllib.parse.urlparse(url).netloc + "/",
"Referer": parsed.scheme + "://" + parsed.netloc + "/",
})
resp = urllib.request.urlopen(req, timeout=10, context=_ssl_ctx)
body = resp.read()
ct = resp.headers.get("Content-Type", "image/jpeg")
# Only serve actual image content types
if not ct.startswith("image/"):
handler._send_json({"error": "Response is not an image"}, 400)
return
handler.send_response(200)
handler.send_header("Content-Type", ct)
handler.send_header("Content-Length", len(body))

View File

@@ -42,7 +42,7 @@ class ResponseMixin:
def _set_session_cookie(self, token):
self.send_header("Set-Cookie",
f"platform_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}")
f"platform_session={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={SESSION_MAX_AGE}")
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))

View File

@@ -184,7 +184,7 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
return
if path == "/api/auth/register":
handle_register(self, body)
self._send_json({"error": "Registration is disabled"}, 403)
return
if path == "/api/me/connections":

View File

@@ -1,5 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install bcrypt
COPY server.py .
EXPOSE 8095
ENV PYTHONUNBUFFERED=1
CMD ["python3", "server.py"]

View File

@@ -8,8 +8,8 @@ import os
import json
import sqlite3
import uuid
import hashlib
import secrets
import bcrypt
import re
import unicodedata
from http.server import HTTPServer, BaseHTTPRequestHandler
@@ -517,21 +517,21 @@ def seed_default_users():
"username": os.environ.get("USER1_USERNAME", "yusuf"),
"password": os.environ.get("USER1_PASSWORD", "changeme"),
"display_name": os.environ.get("USER1_DISPLAY_NAME", "Yusuf"),
"telegram_user_id": os.environ.get("USER1_TELEGRAM_ID", "5878604567"),
"telegram_user_id": os.environ.get("USER1_TELEGRAM_ID"),
},
{
"id": str(uuid.uuid4()),
"username": os.environ.get("USER2_USERNAME", "madiha"),
"password": os.environ.get("USER2_PASSWORD", "changeme"),
"display_name": os.environ.get("USER2_DISPLAY_NAME", "Madiha"),
"telegram_user_id": os.environ.get("USER2_TELEGRAM_ID", "6389024883"),
"telegram_user_id": os.environ.get("USER2_TELEGRAM_ID"),
},
]
for user in users:
existing = cursor.execute("SELECT id FROM users WHERE username = ?", (user["username"],)).fetchone()
if not existing:
password_hash = hashlib.sha256(user["password"].encode()).hexdigest()
password_hash = bcrypt.hashpw(user["password"].encode(), bcrypt.gensalt()).decode()
cursor.execute(
"INSERT INTO users (id, username, password_hash, display_name, telegram_user_id) VALUES (?, ?, ?, ?, ?)",
(user["id"], user["username"], password_hash, user["display_name"], user["telegram_user_id"])
@@ -2208,16 +2208,14 @@ class CalorieHandler(BaseHTTPRequestHandler):
data = self._read_body()
username = data.get('username', '').strip().lower()
password = data.get('password', '')
password_hash = hashlib.sha256(password.encode()).hexdigest()
conn = get_db()
user = conn.execute(
"SELECT * FROM users WHERE username = ? AND password_hash = ?",
(username, password_hash)
"SELECT * FROM users WHERE username = ?",
(username,)
).fetchone()
conn.close()
if not user:
if not user or not bcrypt.checkpw(password.encode(), user['password_hash'].encode()):
return self._send_json({'error': 'Invalid credentials'}, 401)
token = create_session(user['id'])

View File

@@ -1,4 +0,0 @@
{
"status": "failed",
"failedTests": []
}