feat: enhance URL validation and sanitization in loading page; improve file extraction safety in plugins

This commit is contained in:
Théophile Diot 2024-12-05 15:30:14 +01:00
parent bad9c3cc5e
commit 10c9f29f13
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
5 changed files with 63 additions and 25 deletions

View file

@ -1,3 +1,4 @@
from html import escape
from importlib.machinery import SourceFileLoader
from io import BytesIO
from json import JSONDecodeError, loads as json_loads
@ -102,7 +103,18 @@ def run_action(plugin: str, function_name: str = "", *, tmp_dir: Optional[Path]
tmp_dir.mkdir(parents=True, exist_ok=True)
with tar_open(fileobj=BytesIO(page), mode="r:gz") as tar:
tar.extractall(tmp_dir)
for member in tar.getmembers():
# Prevent absolute paths and paths with '..'
if member.name.startswith("/") or ".." in Path(member.name).parts:
return {"status": "ko", "code": 400, "message": "Invalid file path"}
# Construct the target path and ensure it is within tmp_dir
target_path = tmp_dir.joinpath(member.name).resolve()
if not str(target_path).startswith(str(tmp_dir)):
return {"status": "ko", "code": 400, "message": "Invalid file path"}
# Extract the file safely
tar.extract(member, tmp_dir)
tmp_dir = tmp_dir.joinpath("ui")
except BaseException as e:
@ -479,16 +491,15 @@ def custom_plugin_page(plugin: str):
return handle_error("Invalid plugin id, (must be between 1 and 64 characters, only letters, numbers, underscores and hyphens)", "plugins")
if request.method == "POST":
action_result = run_action(plugin)
if isinstance(action_result, Response):
LOGGER.info(f"Plugin {plugin} action executed successfully")
LOGGER.info("Plugin action executed successfully")
return action_result
# case error
if action_result["status"] == "ko":
return error_message(action_result["message"]), action_result["code"]
return error_message(escape(action_result["message"])), action_result["code"]
LOGGER.info(f"Plugin {plugin} action executed successfully")
@ -541,8 +552,19 @@ def custom_plugin_page(plugin: str):
tmp_page_dir = TMP_DIR.joinpath("ui", "page", str(uuid4()))
tmp_page_dir.mkdir(parents=True, exist_ok=True)
with tar_open(fileobj=BytesIO(page), mode="r:gz") as tar_file:
tar_file.extractall(tmp_page_dir)
with tar_open(fileobj=BytesIO(page), mode="r:gz") as tar:
for member in tar.getmembers():
# Prevent absolute paths and paths with '..'
if member.name.startswith("/") or ".." in Path(member.name).parts:
return {"status": "ko", "code": 400, "message": "Invalid file path"}
# Construct the target path and ensure it is within tmp_dir
target_path = tmp_page_dir.joinpath(member.name).resolve()
if not str(target_path).startswith(str(tmp_page_dir)):
return {"status": "ko", "code": 400, "message": "the plugin page has an invalid file path"}
# Extract the file safely
tar.extract(member, tmp_page_dir)
tmp_page_dir = tmp_page_dir.joinpath("ui")
@ -570,15 +592,8 @@ def custom_plugin_page(plugin: str):
.from_string(page_content)
.render(pre_render=pre_render, **current_app.jinja_env.globals)
)
except BaseException as e:
except BaseException:
LOGGER.exception("An error occurred while rendering the plugin page")
plugin_page = f'<div class="mt-2 mb-2 alert alert-danger text-center" role="alert">An error occurred while rendering the plugin page: {e}<br/>See logs for more details</div>'
plugin_page = '<div class="mt-2 mb-2 alert alert-danger text-center" role="alert">An error occurred while rendering the plugin page<br/>See logs for more details</div>'
return render_template(
"plugin_page.html",
plugin_page=plugin_page,
plugin=plugin_data,
is_used=is_used,
is_metrics=is_metrics_on,
pre_render=pre_render,
)
return render_template("plugin_page.html", plugin_page=plugin_page, plugin=plugin_data, is_used=is_used, is_metrics=is_metrics_on, pre_render=pre_render)

View file

@ -5,7 +5,7 @@ from os import environ, getenv
# from secrets import choice
# from string import ascii_letters, digits
from re import escape, match
from time import sleep, time
from time import sleep
from flask import Blueprint, Response, flash, redirect, render_template, request, url_for
from flask_login import current_user
@ -140,9 +140,6 @@ def setup_page():
if not REVERSE_PROXY_PATH.match(request.form["ui_host"]):
return handle_error("The hostname is not valid.", "setup")
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
base_config = {
"SERVER_NAME": request.form["server_name"],
"USE_UI": "yes",

View file

@ -5,7 +5,26 @@ $(document).ready(function () {
? $targetEndpoint.val().trim()
: null;
const nextEndpoint = $("#next-endpoint").val().trim();
let nextEndpoint = $("#next-endpoint").val().trim();
// Function to validate and sanitize the URL
function sanitizeUrl(url) {
try {
// Ensure the URL is either relative or shares the same origin
const validUrl = new URL(url, window.location.origin);
return validUrl.href;
} catch (e) {
console.error("Invalid URL detected:", url);
}
return null; // Return null if the URL is invalid
}
// Sanitize nextEndpoint
nextEndpoint = sanitizeUrl(nextEndpoint);
if (!nextEndpoint) {
console.error("Invalid or missing nextEndpoint. Redirect aborted.");
return; // Abort further execution if the endpoint is invalid
}
// Start the reloading interval to check every 2 seconds
var reloadingInterval = setInterval(check_reloading, 2000);
@ -28,8 +47,12 @@ $(document).ready(function () {
// Clear all intervals and timeout
clearInterval(reloadingInterval);
// Redirect to the next page with the current hash if present
window.location.replace(nextEndpoint + (window.location.hash || ""));
// Redirect to the sanitized nextEndpoint with the current hash if present
const redirectUrl = new URL(nextEndpoint, window.location.origin);
if (window.location.hash) {
redirectUrl.hash = window.location.hash;
}
window.location.href = redirectUrl.href; // Use window.location.href for compatibility
}
},
error: function (jqXHR, textStatus, errorThrown) {

View file

@ -270,8 +270,10 @@ $(document).ready(function () {
const content = generateSessionContent(session, index);
const placeholder = $(`#session-placeholder-${index}`);
const sanitizedText = DOMPurify.sanitize(content);
placeholder
.html(content)
.html(sanitizedText)
.removeClass("placeholder-transition fade-in")
.addClass("card-transition")
.toggleClass(currentCardClasses, session.current)

View file

@ -354,7 +354,8 @@ $(document).ready(() => {
$bannerText.removeClass("slide-out");
// Update the text content
$bannerText.html(newsItems[nextIndex]);
const sanitizedText = DOMPurify.sanitize(newsItems[nextIndex]);
$bannerText.html(sanitizedText);
// Trigger reflow to ensure the browser applies the changes
$bannerText[0].offsetHeight;