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/ # Secrets and local config
.svelte-kit/
build/
.env .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
*.db-journal *.db-journal
*.db-wal *.db-wal
*.db-shm
data/ data/
__pycache__/ **/data/*.db
*.pyc **/data/*.json
.DS_Store
services/fitness/data/ services/fitness/data/
services/trips/data/ services/trips/data/
gateway/data/ gateway/data/
frontend-v2/.svelte-kit/
frontend-v2/build/ # OS
frontend-v2/node_modules/ .DS_Store
# Media
*.png *.png
# Test artifacts
test-results/

View File

@@ -8,8 +8,26 @@ const karakeepUrl = env.KARAKEEP_URL || '';
const karakeepApiKey = env.KARAKEEP_API_KEY || ''; const karakeepApiKey = env.KARAKEEP_API_KEY || '';
export const handle: Handle = async ({ event, resolve }) => { 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) ── // ── Immich API proxy (shared across all pages) ──
if (event.url.pathname.startsWith('/api/immich/') && immichUrl && immichApiKey) { 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'); const immichPath = event.url.pathname.replace('/api/immich', '/api');
// Thumbnail/original image proxy — cache-friendly binary response // 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) ── // ── Karakeep API proxy (server-to-server) ──
if (event.url.pathname.startsWith('/api/karakeep/') && karakeepUrl && karakeepApiKey) { 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' const action = event.url.pathname.split('/').pop(); // 'save' or 'delete'
try { try {
const body = await event.request.json(); 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) // Legacy trips-specific Immich thumbnail (keep for backward compat)
if (event.url.pathname.startsWith('/api/trips/immich/thumb/') && immichUrl && immichApiKey) { 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(); const assetId = event.url.pathname.split('/').pop();
try { try {
const response = await fetch(`${immichUrl}/api/assets/${assetId}/thumbnail`, { 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 import urllib.request
@@ -7,24 +7,65 @@ import urllib.parse
from config import _ssl_ctx 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): 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 qs = urllib.parse.urlparse(handler.path).query
params = urllib.parse.parse_qs(qs) params = urllib.parse.parse_qs(qs)
url = params.get("url", [None])[0] url = params.get("url", [None])[0]
if not url: if not url:
handler._send_json({"error": "Missing url parameter"}, 400) handler._send_json({"error": "Missing url parameter"}, 400)
return 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: try:
req = urllib.request.Request(url, headers={ req = urllib.request.Request(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; PlatformProxy/1.0)", "User-Agent": "Mozilla/5.0 (compatible; PlatformProxy/1.0)",
"Accept": "image/*,*/*", "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) resp = urllib.request.urlopen(req, timeout=10, context=_ssl_ctx)
body = resp.read() body = resp.read()
ct = resp.headers.get("Content-Type", "image/jpeg") 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_response(200)
handler.send_header("Content-Type", ct) handler.send_header("Content-Type", ct)
handler.send_header("Content-Length", len(body)) handler.send_header("Content-Length", len(body))

View File

@@ -42,7 +42,7 @@ class ResponseMixin:
def _set_session_cookie(self, token): def _set_session_cookie(self, token):
self.send_header("Set-Cookie", 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): def _read_body(self):
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))

View File

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

View File

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

View File

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

View File

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