Skip to content

fix: restrict server file access to known logs and plain names (#374) - #376

Merged
timdegroot1996 merged 1 commit into
mainfrom
fix/374-server-path-traversal
Oct 13, 2026
Merged

timdegroot1996 merged 1 commit into
mainfrom
fix/374-server-path-traversal

Conversation

@timdegroot1996

Copy link
Copy Markdown
Collaborator

Fixes #374

Problem

/log?path= served any file the server process could read. While investigating, I found the same root cause in several other server endpoints: a path or file name from the request was used without restricting it to the server's own folders. Some of these endpoints write or delete files. They are open by default, because auth is only enabled when credentials are configured.

Root cause

  • /log resolved and read path with no allow-list. It also set latest_log_dir to that file's folder, so the catch-all route then served everything in it. The catch-all checked containment with a string prefix (/srv/logs also matched /srv/logs-secret). The not-found page echoed path without HTML escaping.
  • /add-log, /add-log-file, /remove-log, /add-output-file and output_alias in /add-outputs joined request file names onto a folder (or the working directory) without checking them.
  • The run delete and path update queries were built with .format().

Fix

  • /log only serves files inside robot_logs/, or the log next to an output stored in the database (output-XYZ.xml ↔ log-XYZ.html, the same mapping the dashboard uses). Anything else returns a real 404 with the path HTML-escaped, including malformed paths.
  • Catch-all checks containment with relative_to. It only serves file types a log embeds or links to (images, video, HTML such as the report), so a database or secret file next to a log is not served.
  • File names taken from a request must be plain names (server_paths.safe_file_name). Names with a folder part are rejected with an error response. The listener and admin page already send plain names.
  • Output uploads (/add-output-file, /add-outputs with output_data) are processed from a TemporaryDirectory instead of the working directory, so an upload can't overwrite or delete an existing file there.
  • SQL: run deletes and the run path update now use bound parameters (new *_BY_RUN_START constants). The old .format() constants stay, marked deprecated, so custom database classes that import them keep working. example/database/sqlite3.py mirrors the change.
  • Docs: dashboard-server.md, custom-database-class.md and the server-api skill describe the new restrictions.

Behaviour changes to note

  • For an upload with no log linked yet, the stored run path now points into a temp folder that no longer exists, where before it pointed into the working directory. Once a log is uploaded, update_output_path replaces the path as before.
  • With a custom --databaseclass that doesn't implement the optional _get_run_paths, /log only serves logs from robot_logs/.
  • /log returns status 404 instead of 200 for unknown logs.

Tests

  • 26 new or updated pytest cases in tests/python/test_server.py and test_database.py: read, relative and absolute traversal on /log, sibling-prefix and file-type checks on the catch-all, escaping, malformed paths, rejected names on every write/remove endpoint, the working directory staying untouched, and a quote in a stored path.
  • The new tests fail on main (12 failed before the follow-up review fixes were added) and pass on this branch.
  • Python: 434 passed. Robot (Docker): 132 tests, 132 passed, 0 failed.
  • Tested by hand against a live --server from source: the issue's curl now returns 404, and upload/remove with ../ is rejected while the target file stays intact.

Out of scope

  • Auth is off by default and there is no CSRF protection on mutating endpoints. A multipart POST from any web page can reach a local server. This needs a design decision (Origin check, or require credentials).
  • output_path / output_folder_path in /add-outputs read any server path by design.

🤖 Generated with Claude Code

/log read any file the server process could access, and the file it
read set latest_log_dir, which let the unauthenticated catch-all route
serve every file in that folder. The not-found page also echoed the
path unescaped. Several mutation endpoints had the same root cause:
file names from the request were joined to a folder unchecked, so
/add-log, /add-log-file, /remove-log, /add-output-file and the
output_alias of /add-outputs could write or delete files outside their
folder. These endpoints are open by default because auth is off
unless credentials are configured.

- /log only serves files in the log folder, or the log next to an
  output stored in the database. Anything else gets a 404 page with the
  path HTML-escaped.
- The catch-all checks containment with relative_to instead of a string
  prefix, and only serves file types a log embeds (images, video, HTML).
- Request file names must be plain names (server_paths.safe_file_name).
- Uploaded outputs are processed from a TemporaryDirectory, so an upload
  can no longer overwrite or delete a file in the working directory.
- Run deletes and the path update use bound parameters. The old
  .format() constants are kept for custom database classes that import
  them, and example/database/sqlite3.py mirrors the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@timdegroot1996
timdegroot1996 merged commit 3c8c2a1 into main Oct 13, 2026
3 checks passed
@timdegroot1996
timdegroot1996 deleted the fix/374-server-path-traversal branch October 14, 2026 00:20
@timdegroot1996 timdegroot1996 mentioned this pull request Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Path Traversal weakness in /log endpoint

1 participant