fix: code review — security fixes, dedup, cleanup

- setup: validate roles against users.VALID_ROLES (Supervisor was missing)
- files: fix path traversal prefix-match edge case (is_relative_to),
  dedupe path validation into resolve_upload_path(), use logging not print
- measurements: extract shared _build_measurement_filters() helper
- client app: prevent open redirect via Referer on /set-language
- maker: guard resp.json() in parse-technical-sheet proxy
- measure/maker: extract shared file proxy into services/file_proxy.py
- measure: localize supervisor validation error messages
- annotation-editor: remove global keydown listener in destroy()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 12:16:29 +00:00
parent 25a788f430
commit fd571c479e
8 changed files with 141 additions and 134 deletions
+37 -49
View File
@@ -1,5 +1,5 @@
"""File upload/download/delete router for images and PDFs.""" """File upload/download/delete router for images and PDFs."""
import os import logging
import re import re
from pathlib import Path from pathlib import Path
@@ -11,6 +11,8 @@ from src.backend.config import settings
from src.backend.api.middleware.api_key import get_current_user, require_maker from src.backend.api.middleware.api_key import get_current_user, require_maker
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"]) router = APIRouter(prefix="/api/files", tags=["files"])
# Allowed MIME types # Allowed MIME types
@@ -65,6 +67,37 @@ def sanitize_filename(filename: str) -> str:
return filename return filename
def resolve_upload_path(file_path: str) -> Path:
"""Resolve a user-supplied relative path inside the uploads directory.
Raises:
HTTPException: 404 if the path escapes the uploads directory,
cannot be resolved, or does not point to an existing file.
"""
try:
full_path = (settings.upload_path / file_path).resolve()
if not full_path.is_relative_to(settings.upload_path.resolve()):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except HTTPException:
raise
except (OSError, RuntimeError, ValueError):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
return full_path
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)): def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
"""Generate a thumbnail for an image.""" """Generate a thumbnail for an image."""
try: try:
@@ -73,7 +106,7 @@ def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[i
img.save(thumbnail_path, quality=85) img.save(thumbnail_path, quality=85)
except Exception as e: except Exception as e:
# If thumbnail generation fails, we continue without it # If thumbnail generation fails, we continue without it
print(f"Thumbnail generation failed: {e}") logger.warning("Thumbnail generation failed for %s: %s", image_path, e)
@router.post("/upload") @router.post("/upload")
@@ -164,30 +197,7 @@ async def get_file(
Requires authentication but no specific role. Requires authentication but no specific role.
""" """
# Construct full path full_path = resolve_upload_path(file_path)
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
return FileResponse(full_path) return FileResponse(full_path)
@@ -200,29 +210,7 @@ async def delete_file(
Also deletes associated thumbnail if it exists. Also deletes associated thumbnail if it exists.
""" """
# Construct full path full_path = resolve_upload_path(file_path)
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Delete thumbnail if it exists # Delete thumbnail if it exists
if full_path.parent.name != "thumbnails": if full_path.parent.name != "thumbnails":
+44 -45
View File
@@ -84,6 +84,44 @@ async def create_measurement_batch(
) )
def _build_measurement_filters(
recipe_id: int | None,
version_id: int | None,
subtask_id: int | None,
measured_by: int | None,
lot_number: str | None,
serial_number: str | None,
date_from: datetime | None,
date_to: datetime | None,
pass_fail: str | None,
) -> list:
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
filters = []
if recipe_id is not None:
# Filter by recipe via subquery on RecipeVersion
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
return filters
@router.get("/", response_model=MeasurementListResponse) @router.get("/", response_model=MeasurementListResponse)
async def get_measurements( async def get_measurements(
recipe_id: int | None = Query(None), recipe_id: int | None = Query(None),
@@ -108,30 +146,10 @@ async def get_measurements(
filters. Measurements carry no PII beyond numeric values + a recipe filters. Measurements carry no PII beyond numeric values + a recipe
reference, so role-gating beyond authentication isn't justified. reference, so role-gating beyond authentication isn't justified.
""" """
# Build filter conditions filters = _build_measurement_filters(
filters = [] recipe_id, version_id, subtask_id, measured_by, lot_number,
if recipe_id is not None: serial_number, date_from, date_to, pass_fail,
# Filter by recipe via subquery on RecipeVersion
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
) )
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
# Build query # Build query
query = select(Measurement).where(and_(*filters) if filters else True) query = select(Measurement).where(and_(*filters) if filters else True)
@@ -196,29 +214,10 @@ async def export_measurements_csv(
decimal_setting = decimal_result.scalar_one_or_none() decimal_setting = decimal_result.scalar_one_or_none()
decimal_separator = decimal_setting.setting_value if decimal_setting else "." decimal_separator = decimal_setting.setting_value if decimal_setting else "."
# Build filter conditions (same as get_measurements) filters = _build_measurement_filters(
filters = [] recipe_id, version_id, subtask_id, measured_by, lot_number,
if recipe_id is not None: serial_number, date_from, date_to, pass_fail,
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
) )
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
# Query all matching measurements # Query all matching measurements
query = select(Measurement).where(and_(*filters) if filters else True) query = select(Measurement).where(and_(*filters) if filters else True)
+1 -1
View File
@@ -15,6 +15,7 @@ from pydantic import BaseModel
from sqlalchemy import inspect as sa_inspect, select from sqlalchemy import inspect as sa_inspect, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.routers.users import VALID_ROLES
from src.backend.config import settings from src.backend.config import settings
from src.backend.database import Base, engine, async_session_factory from src.backend.database import Base, engine, async_session_factory
from src.backend.models.orm import ( from src.backend.models.orm import (
@@ -544,7 +545,6 @@ async def manage_user(body: ManageUserBody):
"""Create or update a user. user_id=null creates new, user_id=int updates.""" """Create or update a user. user_id=null creates new, user_id=int updates."""
_check_password(body.password) _check_password(body.password)
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
invalid = set(body.roles) - VALID_ROLES invalid = set(body.roles) - VALID_ROLES
if invalid: if invalid:
raise HTTPException( raise HTTPException(
+9 -1
View File
@@ -1,6 +1,7 @@
"""TieMeasureFlow Client - Flask Entry Point.""" """TieMeasureFlow Client - Flask Entry Point."""
import json import json
import os import os
from urllib.parse import urlparse
from flask import Flask, redirect, url_for, session, request from flask import Flask, redirect, url_for, session, request
from flask_babel import Babel from flask_babel import Babel
@@ -63,7 +64,14 @@ def create_app() -> Flask:
"""Set user's preferred language and store in session.""" """Set user's preferred language and store in session."""
if lang in Config.LANGUAGES: if lang in Config.LANGUAGES:
session["language"] = lang session["language"] = lang
return redirect(request.referrer or url_for("auth.login")) # Only follow the referrer if it points back to this host
# (prevents open redirect via a forged Referer header).
referrer = request.referrer
if referrer:
parsed = urlparse(referrer)
if parsed.netloc and parsed.netloc != request.host:
referrer = None
return redirect(referrer or url_for("auth.login"))
@app.template_filter("tojson_attr") @app.template_filter("tojson_attr")
def tojson_attr_filter(value): def tojson_attr_filter(value):
+11 -15
View File
@@ -1,12 +1,13 @@
"""Maker blueprint - recipe creation and editing.""" """Maker blueprint - recipe creation and editing."""
import requests as http_requests import requests as http_requests
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from blueprints.auth import login_required, role_required from blueprints.auth import login_required, role_required
from config import Config from config import Config
from services.api_client import api_client from services.api_client import api_client
from services.file_proxy import proxy_file
maker_bp = Blueprint("maker", __name__, url_prefix="/maker") maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
@@ -380,19 +381,7 @@ def api_upload_file():
@login_required @login_required
def api_get_file(file_path: str): def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key).""" """Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "") return proxy_file(file_path)
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"]) @maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
@@ -444,4 +433,11 @@ def api_parse_technical_sheet(recipe_id: int):
timeout=90, timeout=90,
) )
return jsonify(resp.json()), resp.status_code try:
payload = resp.json()
except ValueError:
payload = {
"error": True,
"detail": resp.text or f"HTTP {resp.status_code}",
}
return jsonify(payload), resp.status_code
+6 -19
View File
@@ -1,8 +1,6 @@
"""MeasurementTec blueprint - recipe selection and measurement execution.""" """MeasurementTec blueprint - recipe selection and measurement execution."""
import requests as http_requests
from flask import ( from flask import (
Blueprint, Response, flash, jsonify, redirect, render_template, Blueprint, flash, jsonify, redirect, render_template,
request, session, url_for, request, session, url_for,
) )
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -10,6 +8,7 @@ from flask_babel import gettext as _
from blueprints.auth import login_required, role_required from blueprints.auth import login_required, role_required
from config import Config from config import Config
from services.api_client import api_client from services.api_client import api_client
from services.file_proxy import proxy_file
measure_bp = Blueprint("measure", __name__) measure_bp = Blueprint("measure", __name__)
@@ -339,17 +338,17 @@ def validate_supervisor():
password = data.get("password", "") password = data.get("password", "")
if not username or not password: if not username or not password:
return jsonify({"error": True, "detail": "Username e password richiesti"}), 400 return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
resp = api_client.post("/api/auth/login", data={"username": username, "password": password}) resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
if resp.get("error"): if resp.get("error"):
return jsonify({"error": True, "detail": "Credenziali non valide"}), 401 return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
user = resp.get("user", {}) user = resp.get("user", {})
is_supervisor = "Supervisor" in (user.get("roles") or []) is_supervisor = "Supervisor" in (user.get("roles") or [])
if not (is_supervisor or user.get("is_admin")): if not (is_supervisor or user.get("is_admin")):
return jsonify({"error": True, "detail": "Utente non autorizzato (richiesto capoturno)"}), 403 return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200 return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
@@ -361,16 +360,4 @@ def validate_supervisor():
@login_required @login_required
def api_get_file(file_path: str): def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key).""" """Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "") return proxy_file(file_path)
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@@ -0,0 +1,25 @@
"""Shared file proxy: relay uploads from the FastAPI server to the browser.
The browser can't send the X-API-Key header directly, so blueprints expose
a proxy route and delegate here.
"""
import requests as http_requests
from flask import Response, session
from config import Config
def proxy_file(file_path: str) -> Response:
"""Fetch a file from the API server and relay it with its content type."""
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@@ -956,7 +956,7 @@ function annotationEditor() {
setupKeyboard() { setupKeyboard() {
var self = this; var self = this;
document.addEventListener('keydown', function (e) { this._onKeyDown = function (e) {
// Skip when typing in form fields // Skip when typing in form fields
var tag = e.target.tagName; var tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
@@ -970,7 +970,8 @@ function annotationEditor() {
self.canvas.discardActiveObject(); self.canvas.discardActiveObject();
self.canvas.renderAll(); self.canvas.renderAll();
} }
}); };
document.addEventListener('keydown', this._onKeyDown);
}, },
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -1289,6 +1290,9 @@ function annotationEditor() {
if (this._onImageLoaded) { if (this._onImageLoaded) {
window.removeEventListener('image-loaded', this._onImageLoaded); window.removeEventListener('image-loaded', this._onImageLoaded);
} }
if (this._onKeyDown) {
document.removeEventListener('keydown', this._onKeyDown);
}
if (this.canvas) { if (this.canvas) {
this.canvas.dispose(); this.canvas.dispose();
this.canvas = null; this.canvas = null;