Skip to content

Commit 4da7abb

Browse files
author
Nils Bars
committed
Harden admin file-browser load-file endpoint
- Decode file contents with UTF-8 and fall back to charset_normalizer detection so arbitrary user encodings render correctly - Reject files containing NUL bytes in the first 8 KiB as binary - Cap the read at 5 MiB by bounding the read syscall, avoiding a stat/read TOCTOU on growing files - Render read failures and decode errors through the shared alert template and surface them to the UI via a jQuery .fail handler
1 parent 7f5b239 commit 4da7abb

2 files changed

Lines changed: 43 additions & 6 deletions

File tree

webapp/ref/templates/file_browser/file_browser.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ <h4 class="card-header">
7171
function load_file(fileName) {
7272
let url = "{{ url_for('ref.file_browser_load_file') }}";
7373

74+
$('#error-log').empty();
75+
7476
$.post(url, { 'path': fileName, 'token': token, 'hide_hidden_files': hide_hidden_files}, function (response) {
7577
console.log(response)
7678

@@ -97,6 +99,9 @@ <h4 class="card-header">
9799
load_file(current_path)
98100
});
99101
}
102+
})
103+
.fail(function (error) {
104+
$('#error-log').html(error.responseText);
100105
});
101106
}
102107

webapp/ref/view/file_browser.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
from pathlib import Path
55

6+
import charset_normalizer
67
from flask import Response, abort, current_app, render_template, request, url_for
78
from itsdangerous import URLSafeTimedSerializer
89

@@ -12,13 +13,21 @@
1213

1314
log = get_logger(__name__)
1415

16+
# Editor is not a data viewer; cap the bytes we'll load into memory per request.
17+
MAX_FILE_BROWSER_BYTES = 5 * 1024 * 1024
18+
1519

1620
@dataclasses.dataclass
1721
class PathSignatureToken:
1822
# Path prefix a request is allowed to access
1923
path_prefix: str
2024

2125

26+
def _alert_response(message: str, status: int) -> Response:
27+
rendered_alert = render_template("file_browser/alert.html", error_message=message)
28+
return Response(rendered_alert, status=status)
29+
30+
2231
def _get_file_list(dir_path, base_dir_path, list_hidden_files=False):
2332
files = []
2433
base_dir_path = base_dir_path.rstrip("/")
@@ -108,13 +117,36 @@ def file_browser_load_file():
108117

109118
response = None
110119
if final_path.is_file():
111-
# If the current path belongs to a file, return the file content.
112-
content = None
120+
# Cap the actual read: the file can grow between stat() and read(), so
121+
# only trusting os.stat() would be racy. Read one byte over the limit
122+
# so we can distinguish "exactly at limit" from "exceeds limit".
123+
try:
124+
with open(final_path, "rb") as f:
125+
raw = f.read(MAX_FILE_BROWSER_BYTES + 1)
126+
except Exception as e:
127+
log.warning(f"Error while reading file {final_path}", exc_info=True)
128+
return _alert_response(f"Error while reading file: {e}", 400)
129+
130+
if len(raw) > MAX_FILE_BROWSER_BYTES:
131+
return _alert_response(
132+
f"File too large to display (max {MAX_FILE_BROWSER_BYTES} bytes).",
133+
400,
134+
)
135+
136+
# Source/text files don't contain NUL bytes; reject binaries early so we
137+
# don't hand the editor garbage.
138+
if b"\x00" in raw[:8192]:
139+
return _alert_response("Binary file is not displayable.", 400)
140+
113141
try:
114-
with open(final_path, "r") as f:
115-
content = f.read()
116-
except Exception:
117-
return Response("Error while reading file: {e}", status=400)
142+
content = raw.decode("utf-8")
143+
except UnicodeDecodeError:
144+
match = charset_normalizer.from_bytes(raw).best()
145+
if match is None:
146+
return _alert_response(
147+
"Unable to decode file: unknown text encoding.", 400
148+
)
149+
content = str(match)
118150

119151
file_extension = final_path.suffix
120152

0 commit comments

Comments
 (0)