From 5f805ceff62c96d243d9bb1da8f1c83b5e9ee386 Mon Sep 17 00:00:00 2001 From: Montana Date: Wed, 1 Jul 2026 12:54:44 -0700 Subject: [PATCH] Fix reflected XSS in echo_jwt and harden JWT handling - HTML-escape the reflected token and decoded payload (closes unauthenticated reflected XSS on all UI-extension routes) - Pin jwt.decode algorithms=["HS256"] to prevent algorithm confusion - Use hmac.compare_digest for the X-Perc-App-Secret comparison - Default app.run to debug=False, opt-in via FLASK_DEBUG - Use request.path instead of base_url.split("/")[-1] - Bump python-jose (CVE-2024-33663, CVE-2024-33664) and Flask off the 2019 pins, which also fail to import on Python >= 3.10 --- app.py | 32 ++++++++++++++++++++++++-------- requirements.txt | 11 +++-------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app.py b/app.py index 715145c..90a9daa 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,17 @@ +import hmac import os import pprint from flask import Flask, request -from jose import ExpiredSignatureError, JWTError, jwt +from jose import JWTError, jwt +from markupsafe import escape app = Flask(__name__, static_url_path="/", static_folder="public") SECRET_HEADER = "X-Perc-App-Secret" +# Only symmetric HMAC is expected; pin it so the token's own header can't +# select a different (or weaker) algorithm. +ALLOWED_ALGORITHMS = ["HS256"] SHOW_TOKEN_HTML = """

jwt

{token}
@@ -40,12 +45,20 @@ def echo_jwt(): return "No JWT found in request" try: - payload = jwt.decode(token, app_secret, options={"verify_aud": False}) + payload = jwt.decode( + token, + app_secret, + algorithms=ALLOWED_ALGORITHMS, + options={"verify_aud": False}, + ) except JWTError: - return SHOW_INVALID_TOKEN_HTML.format(token=token) + # token is attacker-controlled and reflected into the response, so it + # MUST be HTML-escaped to prevent reflected XSS. + return SHOW_INVALID_TOKEN_HTML.format(token=escape(token)) return SHOW_TOKEN_HTML.format( - token=token, decoded_token=pprint.pformat(payload, indent=4) + token=escape(token), + decoded_token=escape(pprint.pformat(payload, indent=4)), ) @@ -60,15 +73,15 @@ def lifecycle_callback(): app_secret = get_app_secret() if not app_secret: print("Cannot validate request, no APP_SECRET environment variable defined") - elif header_secret != app_secret: + elif not header_secret or not hmac.compare_digest(header_secret, app_secret): print( "WARNING! This request may not have come from Percolate, the " "X-Perc-App-Secret header value does not match the APP_SECRET environment " "variable!" ) - message = '"/{}" lifecycle callback endpoint called with data: \n{}'.format( - request.base_url.split("/")[-1], request.get_json() + message = '"{}" lifecycle callback endpoint called with data: \n{}'.format( + request.path, request.get_json() ) print(message) return "{}\n".format(message) @@ -80,4 +93,7 @@ def hello(): if __name__ == "__main__": - app.run(debug=True, port=8000) + # Never default to the Werkzeug debugger in a network-exposed app (the + # README walks users through exposing this via ngrok). Opt in explicitly. + debug = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes") + app.run(debug=debug, port=8000) diff --git a/requirements.txt b/requirements.txt index 9f9c1d3..8a3f6d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,3 @@ -Click==7.0 -Flask==1.1.1 -gunicorn==19.9.0 -itsdangerous==1.1.0 -Jinja2==2.10.1 -MarkupSafe==1.1.1 -Werkzeug==0.15.5 -python-jose==3.0.1 +Flask>=3.0.3 +gunicorn>=22.0.0 +python-jose>=3.4.0