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:
66
claude_remediation_prompt.txt
Normal file
66
claude_remediation_prompt.txt
Normal 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 user’s 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.
|
||||||
@@ -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 . .
|
||||||
|
|
||||||
|
|||||||
@@ -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,))
|
||||||
|
|||||||
Reference in New Issue
Block a user