fix(images): accept Show in folder only as a same-origin JSON request, one at a time

- require an Origin equal to the request's own scheme://host:port and,
  when sent, Sec-Fetch-Site: same-origin; refuse every forwarding-style
  header (Via, X-Forwarded-*, X-Original-*, *-Client-IP, CF-Connecting-IP
  and the rest) as remote (security review M1, L2)
- answer a non-JSON body with 415, so a cross-origin request always needs
  a CORS preflight (L2)
- single flight plus a 1 s cooldown per process: 429 code busy (L1)
- path resolution errors such as ENAMETOOLONG are a JSON 404, and neither
  that nor a start failure logs the client's path at error level (L3)
This commit is contained in:
Vito Sansevero
2026-10-20 21:34:24 -07:00
parent daac06483d
commit 6c4c587d9e
4 changed files with 386 additions and 50 deletions
+48 -6
View File
@@ -40,6 +40,7 @@ try:
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
from ...utils.file_reveal import (
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
reveal_executor,
@@ -55,6 +56,7 @@ except ImportError:
from utils.prompt_inspector import MetadataReadError, read_inspection_file
from utils.file_reveal import (
OpenerUnavailable,
RevealGate,
RevealTargetChanged,
is_local_request,
reveal_executor,
@@ -2222,6 +2224,8 @@ class ImageRoutesMixin:
# Swapped by tests and the disposable browser server; never a shell.
_reveal_opener = staticmethod(reveal_in_file_manager)
# One per process (a class attribute): single flight plus a cooldown.
_reveal_gate = RevealGate()
@staticmethod
def _reveal_path(relative: str, root: Path) -> Path | None:
@@ -2235,11 +2239,22 @@ class ImageRoutesMixin:
async def reveal_image(self, request):
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
Local-only (no proxy header, loopback client and Host): the browser
must run on the computer whose file manager opens.
Local-only (a same-origin browser request from a loopback client, no
forwarding header) and JSON-only: the browser must run on the
computer whose file manager opens. One reveal at a time (429 busy).
"""
if not is_local_request(request.remote, request.headers):
if not is_local_request(request.remote, request.headers, request.scheme):
return _not_local()
if request.content_type != "application/json":
# A non-JSON POST is a CORS "simple" request with no preflight.
return web.json_response(
{
"success": False,
"code": "unsupported-media-type",
"error": "Send the request as application/json.",
},
status=415,
)
data, error = await _json_body_or_error(request)
if error is not None:
return error
@@ -2256,7 +2271,31 @@ class ImageRoutesMixin:
root = roots[index]
if Path(relative).suffix.lower() not in SERVABLE_EXTENSIONS:
return _forbidden("Only media files can be shown")
path = await self._run_in_executor(self._reveal_path, relative, root)
gate = self._reveal_gate
if not gate.try_enter():
return web.json_response(
{
"success": False,
"code": "busy",
"error": "The folder is already opening.",
},
status=429,
)
try:
return await self._reveal(relative, root)
finally:
gate.leave()
async def _reveal(self, relative: str, root: Path):
"""Resolve and open, inside the reveal gate."""
try:
path = await self._run_in_executor(self._reveal_path, relative, root)
except (OSError, ValueError) as exc:
# ENAMETOOLONG and the like: the client's path is not logged.
self.logger.debug(
"Show in folder could not resolve: %s", type(exc).__name__
)
return _reveal_not_found()
if path is None:
return _reveal_not_found()
try:
@@ -2274,8 +2313,11 @@ class ImageRoutesMixin:
},
status=501,
)
except Exception:
self.logger.exception("Show in folder failed")
except Exception as exc:
# The exception text can hold the path: only its type at error
# level, the traceback at debug.
self.logger.error("Show in folder failed: %s", type(exc).__name__)
self.logger.debug("Show in folder failure", exc_info=True)
return web.json_response(
{"success": False, "error": "The folder could not be opened."},
status=500,