CVE Patch Review

GHSA-869J-R97X-HX2G: Path Traversal Hardening in Anki Media Server

GHSA-869J-R97X-HX2G · Updated 2026-06-20 Root-cause

Summary

The patch centralizes path validation for both local media and bundled file access, replacing ad hoc path handling with a shared safe-path routine and converting traversal attempts into explicit 403 responses. It materially addresses the directory traversal primitive described in the advisory, but the provided diff does not show any direct CORS policy changes, so the cross-origin aspect appears mitigated indirectly by blocking file escape rather than by tightening origin controls.

Analysis

Vulnerability

GHSA-869J-R97X-HX2G describes a local path traversal issue in Anki Desktop's local HTTP media server that could be combined with browser behavior to enable cross-origin local file exfiltration. The vulnerable code in qt/aqt/mediasrv.py performed path normalization and prefix checking inline for one access path, while bundled file reads used a separate code path that joined aqt_data_path() / ".." / path and read bytes without the same centralized validation. This created inconsistent trust boundaries around filesystem access.

The core weakness is unsafe path resolution around attacker-controlled request paths. The vulnerable implementation relied on string-prefix validation after path joining, but the validation was not uniformly applied across all file-serving paths. In particular, the bundled file handler directly constructed a path relative to the application data parent and read it, which is exactly the kind of file access pattern the patch later wraps in a dedicated safety helper. Given the advisory context, a malicious website could target the local HTTP server and leverage traversal to read files outside the intended directory scope.

directory = os.path.realpath(directory)
path = os.path.normpath(path)
fullpath = os.path.abspath(os.path.join(directory, path))

# protect against directory transversal
if not fullpath.startswith(directory):
    return _text_response(...)

full_path = aqt_data_path() / ".." / path
return full_path.read_bytes()

Source: official patch commit, GitHub Security Advisory.

Patch

The patch introduces a dedicated UnsafePathException and a shared ensure_safe_path() helper. This helper canonicalizes the base directory with os.path.realpath(), normalizes the requested path with os.path.normpath(), computes the absolute joined path, and rejects any result that does not remain under the intended base directory using base_dir + os.sep as the prefix boundary. That boundary check is stronger than the prior startswith(directory) form because it avoids false positives where sibling paths merely share a textual prefix.

Critically, the patch applies the helper to both the local file request path and the bundled file request path. It also wraps request dispatch in a try/except UnsafePathException block so traversal attempts consistently return HTTP 403 instead of falling through to raw file reads or inconsistent error handling.

class UnsafePathException(Exception):
    def __init__(self, path: str):
        super().__init__(f"Invalid path: {path}")


def ensure_safe_path(base_dir: str | Path, path: str | Path) -> str:
    base_dir = os.path.realpath(base_dir)
    path = os.path.normpath(path)
    fullpath = os.path.abspath(os.path.join(base_dir, path))

    if not fullpath.startswith(base_dir + os.sep):
        raise UnsafePathException(path)
    return fullpath

fullpath = ensure_safe_path(directory, path)
full_path = ensure_safe_path(aqt_data_path().parent, path)
with open(full_path, "rb") as f:
    return f.read()

Source: official patch commit.

Review

Pros

  • Centralizes path validation into a single helper, reducing the chance of one file-serving path being hardened while another remains exposed.
  • Improves the prefix check from startswith(directory) to startswith(base_dir + os.sep), which better enforces directory containment.
  • Extends validation to bundled file access, which in the vulnerable snippet previously performed a direct read after path construction.
  • Converts traversal detection into an explicit exception and uniform 403 response, improving control-flow clarity and fail-closed behavior.
  • Uses canonicalization of the base directory before comparison, aligning with the secure path-handling guidance referenced in the code comments.

Cons

  • The provided diff does not show any direct change to CORS or origin validation logic. If the product also relied on permissive cross-origin access, this patch addresses the file escape primitive but not necessarily broader cross-origin exposure semantics.
  • The containment check is still string-based rather than using a path API such as Path.resolve() plus ancestry comparison. The current approach is acceptable here, but path-object ancestry checks are typically less error-prone.
  • The helper returns a string path and uses abspath on the joined path rather than resolving the final target with symlink-aware semantics. The base directory is canonicalized, but the diff alone does not prove how symlinks inside the served tree are intended to be handled.
  • Error messages include the rejected path string. This is low risk, but it still reflects attacker input back in responses.

Verdict

Root-cause.

Within the scope visible in the patch, this is a strong fix for the directory traversal root cause because it removes inconsistent path handling and enforces a shared containment check across the relevant file-serving code paths. The advisory's cross-origin impact appears to depend on the existence of the traversal primitive; by eliminating filesystem escape, the patch materially blocks the exfiltration path described in the advisory. However, because the diff does not include explicit CORS changes, engineers should verify separately whether origin restrictions remain intentionally permissive and whether any other local HTTP endpoints perform filesystem access without ensure_safe_path().

References: official patch commit, GitHub Security Advisory, third-party report.

Sources