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:
@@ -1,6 +1,7 @@
|
||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, redirect, url_for, session, request
|
||||
from flask_babel import Babel
|
||||
@@ -63,7 +64,14 @@ def create_app() -> Flask:
|
||||
"""Set user's preferred language and store in session."""
|
||||
if lang in Config.LANGUAGES:
|
||||
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")
|
||||
def tojson_attr_filter(value):
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
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 blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file
|
||||
|
||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||
|
||||
@@ -380,19 +381,7 @@ def api_upload_file():
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
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"),
|
||||
)
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
||||
@@ -444,4 +433,11 @@ def api_parse_technical_sheet(recipe_id: int):
|
||||
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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, Response, flash, jsonify, redirect, render_template,
|
||||
Blueprint, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
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 config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file
|
||||
|
||||
measure_bp = Blueprint("measure", __name__)
|
||||
|
||||
@@ -339,17 +338,17 @@ def validate_supervisor():
|
||||
password = data.get("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})
|
||||
|
||||
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", {})
|
||||
is_supervisor = "Supervisor" in (user.get("roles") or [])
|
||||
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
|
||||
|
||||
@@ -361,16 +360,4 @@ def validate_supervisor():
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
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"),
|
||||
)
|
||||
return proxy_file(file_path)
|
||||
|
||||
@@ -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() {
|
||||
var self = this;
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
this._onKeyDown = function (e) {
|
||||
// Skip when typing in form fields
|
||||
var tag = e.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
@@ -970,7 +970,8 @@ function annotationEditor() {
|
||||
self.canvas.discardActiveObject();
|
||||
self.canvas.renderAll();
|
||||
}
|
||||
});
|
||||
};
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
@@ -1289,6 +1290,9 @@ function annotationEditor() {
|
||||
if (this._onImageLoaded) {
|
||||
window.removeEventListener('image-loaded', this._onImageLoaded);
|
||||
}
|
||||
if (this._onKeyDown) {
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.dispose();
|
||||
this.canvas = null;
|
||||
|
||||
Reference in New Issue
Block a user