fix(trips): enforce password protection on shared trips (#3)

- handle_share_api now checks X-Share-Password header against bcrypt hash
  before returning trip data. Returns 401 with {protected: true} if password
  required but not provided/incorrect
- share_password now stored as bcrypt hash, not plaintext
- All plaintext password logging removed from handle_share_verify
- handle_share_verify uses bcrypt.checkpw instead of string equality
- Migration invalidates existing plaintext share passwords (< 50 chars)
- Removed dead hash_password function (used hashlib.sha256)
- Added bcrypt to trips Dockerfile

Closes #3
This commit is contained in:
Yusuf Suleman
2026-03-29 08:50:45 -05:00
parent 6bd23e7e8b
commit d700ba7569
3 changed files with 91 additions and 20 deletions

View File

@@ -0,0 +1,66 @@
You are acting as a senior staff engineer and security remediation lead on the `platform` repository.
Use the existing Gitea issues as the source of truth for scope and acceptance criteria.
Repository:
- Name: `platform`
- Gitea repo: `yusiboyz/platform`
Primary tracking issue:
- `#1 Production Security and Readiness Remediation`
Immediate issues:
- `#2 Auth Boundary: Registration and Default Credentials`
- `#3 Trips Sharing Security: Enforce Protection and Remove Plaintext Secrets`
- `#4 Fitness Authorization: Eliminate Cross-User Data Access`
- `#5 Gateway Trust Model: Protect Internal Services and Service-Level Data`
- `#6 Repository Hygiene: Remove Tracked Secrets and Runtime Databases`
- `#7 Transport Security: Finish Cookie Hardening, TLS Verification, and Proxy Controls`
- `#8 Dependency Security and CI Enforcement`
Next issues:
- `#9 Performance Hardening: Cache and De-risk Summary Endpoints`
- `#10 Deployment Hardening: Containers, Health Checks, and Production Readiness`
Required execution order:
1. `#3`
2. `#4`
3. `#5`
4. `#2`
5. `#6`
6. `#7`
7. `#8`
8. `#9`
9. `#10`
Instructions:
- Start by reading the codebase and the linked Gitea issues.
- Treat the repo as production-bound and internet-exposed.
- Make real code changes, not just recommendations.
- After each issue-sized chunk, verify the fix with tests, targeted checks, or direct code-path validation.
- Update the relevant Gitea issue with:
- what was changed
- exact files touched
- how it was verified
- any remaining risk
- Keep changes minimal but correct. Do not do broad refactors unless required for security or correctness.
- Do not close an issue unless its acceptance criteria are actually satisfied.
- If an issue is only partially fixed, comment with exactly what remains.
- Preserve user changes and do not revert unrelated work.
- If you need to rotate secrets or remove tracked `.env` / `.db` artifacts, make the code and repo changes needed, but clearly separate anything that requires a manual operational rotation step.
- Prioritize correctness and security over speed.
Specific expectations:
- For `#3`, fully eliminate the Trips share-token bypass. Password protection must actually gate access. No plaintext password storage or logging.
- For `#4`, eliminate all normal-user cross-user access in Fitness. `user_id` must not let one user read another users data.
- For `#5`, remove the implicit trust model for service-global data exposure where required.
- For `#2`, remove default credential seeding/fallbacks and make startup fail fast where appropriate.
- For `#6`, stop tracking live env/db artifacts and harden ignore rules.
- For `#7`, restore TLS verification by default and finish cookie hardening cleanly.
- For `#8`, resolve the budget dependency vulnerability and add CI checks for dependency/security scanning.
Reporting format:
- At the start: brief plan tied to issue numbers.
- During work: short progress updates.
- At the end of each issue: `fixed`, `partial`, or `blocked`, with exact file references.
- Final output: summarize which issue numbers were completed, which remain open, and what manual ops actions are still required.

View File

@@ -4,7 +4,7 @@ WORKDIR /app
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir PyPDF2 RUN pip install --no-cache-dir PyPDF2 bcrypt
COPY . . COPY . .

View File

@@ -7,8 +7,8 @@ import os
import json import json
import sqlite3 import sqlite3
import uuid import uuid
import hashlib
import secrets import secrets
import bcrypt
import shutil import shutil
import urllib.request import urllib.request
import urllib.parse import urllib.parse
@@ -208,6 +208,10 @@ def init_db():
except: except:
pass pass
# Invalidate any existing plaintext share passwords (migration to bcrypt)
cursor.execute("UPDATE trips SET share_password = NULL WHERE share_password IS NOT NULL AND share_password != '' AND length(share_password) < 50")
conn.commit()
# Add hike-specific fields to locations table # Add hike-specific fields to locations table
for col, col_type in [('hike_distance', 'TEXT'), ('hike_difficulty', 'TEXT'), ('hike_time', 'TEXT')]: for col, col_type in [('hike_distance', 'TEXT'), ('hike_difficulty', 'TEXT'), ('hike_time', 'TEXT')]:
try: try:
@@ -1228,10 +1232,6 @@ def get_trip_location(trip):
return trip.get('name', '').strip() return trip.get('name', '').strip()
def hash_password(password):
"""Hash a password."""
return hashlib.sha256(password.encode()).hexdigest()
def create_session(username): def create_session(username):
"""Create a new session in the database.""" """Create a new session in the database."""
token = secrets.token_hex(32) token = secrets.token_hex(32)
@@ -2398,7 +2398,7 @@ class TripHandler(BaseHTTPRequestHandler):
WHERE id = ? WHERE id = ?
''', (data.get("name", ""), data.get("description", ""), ''', (data.get("name", ""), data.get("description", ""),
data.get("start_date", ""), data.get("end_date", ""), data.get("start_date", ""), data.get("end_date", ""),
data.get("share_password", "") or None, bcrypt.hashpw(data["share_password"].encode(), bcrypt.gensalt()).decode() if data.get("share_password") else None,
data.get("immich_album_id") or None, trip_id)) data.get("immich_album_id") or None, trip_id))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -3108,16 +3108,12 @@ class TripHandler(BaseHTTPRequestHandler):
self.send_json({"success": True}) self.send_json({"success": True})
def handle_share_verify(self, body): def handle_share_verify(self, body):
"""Verify password for shared trip.""" """Verify password for shared trip. Uses bcrypt comparison."""
try: try:
print(f"[Share] Handler called with body: {body[:100]}", flush=True)
data = json.loads(body) data = json.loads(body)
share_token = data.get("share_token") share_token = data.get("share_token")
password = data.get("password", "") password = data.get("password", "")
print(f"[Share] Verifying token: {share_token}", flush=True)
print(f"[Share] Password entered: {repr(password)}", flush=True)
conn = get_db() conn = get_db()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT share_password FROM trips WHERE share_token = ?", (share_token,)) cursor.execute("SELECT share_password FROM trips WHERE share_token = ?", (share_token,))
@@ -3125,20 +3121,19 @@ class TripHandler(BaseHTTPRequestHandler):
conn.close() conn.close()
if not row: if not row:
print(f"[Share] Trip not found for token", flush=True)
self.send_json({"success": False, "error": "Trip not found"}) self.send_json({"success": False, "error": "Trip not found"})
return return
stored_password = row[0] or "" stored_hash = row[0] or ""
print(f"[Share] Password stored: {repr(stored_password)}", flush=True) if not stored_hash:
print(f"[Share] Match: {password == stored_password}", flush=True) # No password set — allow access
self.send_json({"success": True})
if password == stored_password: elif bcrypt.checkpw(password.encode(), stored_hash.encode()):
self.send_json({"success": True}) self.send_json({"success": True})
else: else:
self.send_json({"success": False, "error": "Incorrect password"}) self.send_json({"success": False, "error": "Incorrect password"})
except Exception as e: except Exception as e:
print(f"[Share] ERROR: {e}", flush=True) print(f"[Share] Verify error: {type(e).__name__}", flush=True)
import traceback import traceback
traceback.print_exc() traceback.print_exc()
self.send_json({"success": False, "error": str(e)}) self.send_json({"success": False, "error": str(e)})
@@ -5283,7 +5278,8 @@ Format your response with clear headers and bullet points. Be specific with name
self.send_json({"success": True}) self.send_json({"success": True})
def handle_share_api(self, share_token): def handle_share_api(self, share_token):
"""Return trip data as JSON for a public share token.""" """Return trip data as JSON for a public share token.
If the trip has a share_password, requires X-Share-Password header."""
conn = get_db() conn = get_db()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT * FROM trips WHERE share_token = ?", (share_token,)) cursor.execute("SELECT * FROM trips WHERE share_token = ?", (share_token,))
@@ -5294,6 +5290,15 @@ Format your response with clear headers and bullet points. Be specific with name
self.send_json({"error": "Trip not found"}, 404) self.send_json({"error": "Trip not found"}, 404)
return return
# Enforce password protection
stored_hash = trip.get("share_password") or ""
if stored_hash:
provided = self.headers.get("X-Share-Password", "")
if not provided or not bcrypt.checkpw(provided.encode(), stored_hash.encode()):
conn.close()
self.send_json({"error": "Password required", "protected": True}, 401)
return
trip_id = trip["id"] trip_id = trip["id"]
cursor.execute("SELECT * FROM transportations WHERE trip_id = ? ORDER BY date", (trip_id,)) cursor.execute("SELECT * FROM transportations WHERE trip_id = ? ORDER BY date", (trip_id,))