CVE Patch Review

GHSA-CW6H-FFMH-X6VH: Root-Cause Mitigation for Anki Local File Disclosure

GHSA-CW6H-FFMH-X6VH · Updated 2026-06-22 Root-cause

Summary

The patch materially changes Anki Desktop's trust model for local media access by replacing Referer-based authorization with a per-profile bearer token injected only into privileged webviews, and by sanitizing editor HTML with DOMPurify. This addresses the core same-origin bypass that let imported HTML/SVG content read arbitrary local files through the media server. A follow-up patch also hardens sound path handling against parent-directory references. Residual concerns remain around the breadth of DOMPurify policy and the fact that URL-scheme restrictions are adjacent hardening rather than the primary fix, but the main vulnerability appears addressed at the authorization boundary.

Analysis

Vulnerability

GHSA-CW6H-FFMH-X6VH describes arbitrary local file disclosure in Anki Desktop caused by user-controlled HTML/SVG content inside imported decks bypassing same-origin protections and reading sensitive local files via Anki's local media server. The vulnerable design relied on request context inferred from the Referer header in qt/aqt/mediasrv.py, granting broad API access to requests that appeared to originate from privileged pages such as the editor or deck options. That is a weak authorization primitive because attacker-supplied active content rendered inside trusted origins can inherit or spoof the same browser context.

The patch sources show two exploit-enabling surfaces. First, the editor accepted raw field HTML without sanitization:

fieldStores[index].set(fieldContent);

and was changed to:

fieldStores[index].set(sanitize(fieldContent));

Second, the media server previously derived access from page context extracted from Referer, including privileged contexts such as PageContext.EDITOR and PageContext.DECK_OPTIONS, instead of authenticating the caller directly. This made the local media API reachable from attacker-controlled deck content rendered in a same-origin webview. The advisory and patch references indicate immediate out-of-band exfiltration was possible once local file reads succeeded. See the advisory and implementation changes in the GHSA, PR #3925, and the 25.02.4...25.02.5 compare.

Patch

The primary fix has two coordinated parts.

1. Editor HTML is sanitized with DOMPurify. The patch adds dompurify to package.json and introduces ts/lib/domlib/sanitize.ts:

import DOMPurify from "dompurify";

export function sanitize(html: string): string {
    // We need to treat the text as a document fragment, or a style tag
    // at the start of input will be discarded.
    return DOMPurify.sanitize(html, { FORCE_BODY: true });
}

This sanitizer is then applied in ts/editor/NoteEditor.svelte before field content is stored.

2. Media API authorization is moved from ambient page context to an explicit bearer token. In qt/aqt/mediasrv.py, the old _extract_page_context()-based logic is removed and replaced with a random process-local API key plus a direct authorization check:

_APIKEY = "".join(random.choices(string.ascii_letters + string.digits, k=32))


def _have_api_access() -> bool:
    return request.headers.get("Authorization") == f"Bearer {_APIKEY}"

In qt/aqt/webview.py, a new AuthInterceptor injects that bearer token only for selected privileged AnkiWebViewKind profiles:

class AuthInterceptor(QWebEngineUrlRequestInterceptor):
    _api_enabled = False

    def __init__(self, parent: QObject | None = None, api_enabled: bool = False):
        super().__init__(parent)
        self._api_enabled = api_enabled

    def interceptRequest(self, info):
        from aqt.mediasrv import _APIKEY

        if self._api_enabled:
            info.setHttpHeader(b"Authorization", f"Bearer {_APIKEY}".encode("utf-8"))

The page constructor now selects a profile based on webview kind, enabling API access only for a bounded set including EDITOR, DECK_OPTIONS, DECK_STATS, CHANGE_NOTETYPE, and BROWSER_CARD_INFO. Unprivileged views use a separate profile without the authorization header.

There is also adjacent hardening in PR #4041 for media path handling in sound playback. The new tag.path() implementation strips parent-directory traversal by applying os.path.basename() before joining with the media folder. That is relevant as defense-in-depth for local file access patterns, though it is not the main same-origin fix.

Finally, the 25.02.5 compare adds URL-scheme allowlisting in qt/aqt/url_schemes.py and routes webview link opening through open_url_if_supported_scheme(). This reduces risky external navigation but is secondary to the disclosed issue.

Review

Pros

  • The patch corrects the authorization boundary. Replacing Referer-derived trust with an unforgeable bearer token injected only by privileged webview profiles is a strong architectural improvement and directly addresses the same-origin bypass described in the advisory.
  • Privilege is narrowed by webview kind. The new profile split in qt/aqt/webview.py prevents arbitrary rendered content from automatically inheriting media API access unless it is hosted in an explicitly privileged surface.
  • Sanitizing note editor HTML with DOMPurify removes a major active-content injection vector at ingestion/render time. Given the issue involves user-supplied HTML/SVG in imported decks, this is an important complementary control.
  • The follow-up sound path hardening in PR #4041 closes a separate local path abuse pattern by normalizing filenames to basenames before joining with the media directory.
  • URL-scheme allowlisting in 25.02.5 adds useful defense-in-depth against malicious deck content attempting to trigger dangerous handlers or external applications.

Cons

  • The DOMPurify integration shown in the patch uses the default policy plus FORCE_BODY. The provided snippets do not show any explicit restrictions for SVG, MathML, URL-bearing attributes, or custom protocol handling. Default DOMPurify is generally strong, but for a bug class centered on hostile HTML/SVG, a documented allowlist policy would provide more assurance.
  • The bearer token is process-global and attached at the profile level. That is acceptable for local authorization, but it means any future XSS or script execution bug inside a privileged webview could still exercise the full media API. The patch reduces origin confusion; it does not eliminate the need to keep privileged surfaces script-safe.
  • The selected privileged set in have_api_access is static and broad enough that review should confirm each listed webview genuinely requires unrestricted media API access. Over-entitlement here would preserve unnecessary attack surface.
  • The source snippets do not show server-side path-level restrictions beyond the auth gate for all media endpoints. If sensitive operations remain exposed behind a single bearer capability, compromise of one privileged renderer still has high impact.
  • The URL-scheme controls are useful but orthogonal. They should not be interpreted as part of the core remediation for the same-origin file disclosure.

Verdict

Root-cause.

The main patch in PR #3925 addresses the core flaw by removing ambient trust based on Referer and replacing it with explicit authorization bound to privileged webview profiles. That is the correct fix direction for a same-origin policy bypass in an embedded browser application. The added DOMPurify sanitization further reduces the attacker-controlled content surface that made exploitation practical. The later sound-path changes in PR #4041 are best viewed as additional hardening, not evidence that the original patch was merely superficial. Engineers should still review privileged webview membership and consider a stricter sanitizer policy for SVG-heavy content, but based on the supplied diffs and references, the patch is a substantive root-cause remediation rather than a narrow bandaid.

Sources