Skip to content

fix(media): strip control and bidi characters from outbound filenames#72973

Closed
masatohoshino wants to merge 1 commit into
openclaw:mainfrom
masatohoshino:pr/media-filename-sanitize
Closed

fix(media): strip control and bidi characters from outbound filenames#72973
masatohoshino wants to merge 1 commit into
openclaw:mainfrom
masatohoshino:pr/media-filename-sanitize

Conversation

@masatohoshino

@masatohoshino masatohoshino commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: outbound media filename extracted from HTTP Content-Disposition and URL pathname can carry C0/C1 control, zero-width, and bidi override/isolate formatting characters through src/media/fetch.ts and src/media/web-media.ts to recipient client UI on Telegram / Discord / Matrix / WhatsApp document labels.
  • Why it matters: this is defense-in-depth for display labels, not a MIME/content boundary bypass — the PR does not change file bytes, MIME inference, or channel delivery behavior.
  • What changed: new helper src/media/outbound-filename.ts stripFilenameControlChars is applied at the 4 sites where filename strings cross the untrusted boundary (3 in fetch.ts, 1 in web-media.ts). Channel adapters in extensions/ are untouched.
  • What did NOT change (scope boundary): operator-supplied filename paths (extensions/discord/src/send.shared.ts, extensions/whatsapp/src/inbound/send-api.ts); text body / caption / sender display name / reply quote / embed structured fields; encoding correctness (RFC 5987 charset/lang, mojibake recovery — covered by media: add shared filename decoder #71517).

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

Root Cause (if applicable)

  • Root cause: parseContentDispositionFileName (src/media/fetch.ts) applies path.basename + decodeURIComponent only; loadWebMediaInternal (src/media/web-media.ts) applies path.basename only. Neither path filters invisible / formatting characters before the value reaches the channel-side document UI label.
  • Missing detection / guardrail: no character-class filter at the outbound-media boundary. Existing helpers (sanitizeFilename in src/media/store.ts for local path safety, sanitizeFileName in src/media/file-context.ts for LLM prompt attribute escape, sanitizeAttachmentFilename in src/media/server.ts for HTTP header escape) target other contexts and do not cover this one.
  • Contributing context (if known): the 4 channel adapters share media.fileName from the same upstream extraction sites, so a per-channel fix would duplicate logic; the root sites are the natural single integration point.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/media/outbound-filename.test.ts (new, 27 cases) and src/media/fetch.test.ts (new Content-Disposition integration case).
  • Scenario the test should lock in: each strip range is removed; preserved characters (U+200E, U+200F, U+202F) round-trip unchanged; the integration path through parseContentDispositionFileName returns a sanitized filename.
  • Why this is the smallest reliable guardrail: the helper is a pure function over a closed character set; a unit test per strip range plus boundary preserve cases gives full coverage at low cost. One integration case anchors the fetch-time wiring.
  • Existing test that already covers this (if any): none in src/media/.
  • If no new test is added, why not: N/A.

User-visible / Behavior Changes

Outbound media filenames forwarded to channel document UI labels no longer contain C0/C1 control, zero-width, or bidi override/isolate formatting characters. Other characters (including U+200E / U+200F bidi marks and U+202F narrow no-break space) round-trip unchanged. No defaults or config keys change.

Diagram (if applicable)

N/A.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: Linux (Ubuntu 24.04)
  • Runtime/container: Node 22, pnpm 10.33.0
  • Model/provider: N/A (pure helper + fetch path)
  • Integration/channel (if any): exercised through src/media/fetch.ts (HTTP Content-Disposition) and src/media/web-media.ts (URL pathname); 4 channel adapters consume the result unchanged.
  • Relevant config (redacted): default.

Steps

  1. pnpm install
  2. pnpm test src/media/outbound-filename.test.ts src/media/fetch.test.ts src/media/web-media.test.ts
  3. pnpm check:changed

Expected

  • outbound-filename.test.ts: 27/27 pass.
  • fetch.test.ts + web-media.test.ts: 42/42 pass (includes the new Content-Disposition integration case).
  • check:changed: lanes core, coreTests GREEN (typecheck / lint / conflict-markers / runtime import cycles / pairing guards).

Actual

  • outbound-filename.test.ts: 27 passed (114ms).
  • fetch.test.ts + web-media.test.ts: 42 passed (843ms).
  • check:changed: GREEN, lint 0 warnings 0 errors, 0 runtime cycles, all guards pass.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets

pnpm test src/media/outbound-filename.test.ts src/media/fetch.test.ts src/media/web-media.test.ts (worktree at 654cf20737, rebased onto f20a295782):

src/media/outbound-filename.test.ts          Test Files  1 passed (1)  Tests  27 passed (27)
src/media/fetch.test.ts + web-media.test.ts  Test Files  2 passed (2)  Tests  42 passed (42)

pnpm check:changed:

[check:changed] lanes=core, coreTests
[check:changed] typecheck core ... OK
[check:changed] typecheck core tests ... OK
[check:changed] lint core ... Found 0 warnings and 0 errors.
[check:changed] runtime import cycles ... 0 runtime value cycle(s).
[check:changed] webhook body guard ... OK
[check:changed] pairing store/account guards ... OK
EXIT 0

Unrelated full-suite note: a local full-suite precheck on both the rebased branch and the f20a295782 baseline hit one gateway-lane ERR_WORKER_OUT_OF_MEMORY under concurrent Vitest shards. The affected test passes in isolation and does not touch src/media/. Earlier unrelated Windows ACL / unit-fast failures were fixed upstream and are included in the current rebase.

Human Verification (required)

  • Verified scenarios: helper unit tests across each strip range; integration through parseContentDispositionFileName; preserved characters (U+200E, U+200F, U+202F) round-trip unchanged; pnpm check:changed GREEN on 654cf20737 (rebased onto f20a295782).
  • Edge cases checked: empty input, all-control input, mixed control + printable, characters straddling the strip boundaries (e.g. U+200E LRM kept, U+200B ZWSP stripped), filenames with multibyte characters preserved.
  • What I did not verify: live channel send paths (Telegram / Discord / Matrix / WhatsApp); these consume media.fileName unchanged through extensions/ adapters that are untouched by this PR, so the change is transparent to them.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: false-strip of legitimate characters in filenames.
    • Mitigation: strip range is restricted to C0/C1 controls, zero-width characters, and bidi override/isolate formatting characters. A preserve test asserts that U+200E (LRM), U+200F (RLM), and U+202F (NARROW NO-BREAK SPACE) round-trip unchanged, locking in the boundary.
  • Risk: overlap with media: add shared filename decoder #71517 (encoding correctness in the same parseContentDispositionFileName).
    • Mitigation: this PR operates on the post-decodeURIComponent value; both PRs touch the same function but functionally exclusive. Whichever lands second takes a small rebase.
  • Risk: bidi-affecting directional marks such as LRM (U+200E), RLM (U+200F), and ALM (U+061C) remain in extracted filenames.
    • Mitigation: this is intentional. The PR strips bidi override (U+202A-U+202E) and isolate (U+2066-U+2069) controls, which are the stronger visual-reordering controls relevant to RTLO-style filename deception and Trojan-Source-style visual reordering. LRM/RLM/ALM are directional marks used legitimately in Arabic, Hebrew, and other RTL-script contexts to resolve weak-direction characters such as digits and punctuation in mixed-script filenames. Stripping them would create a concrete multilingual display regression at this filename extraction boundary to mitigate a more theoretical residual surface. If maintainers prefer a stricter policy for all bidi-affecting invisible characters, this strip set can be widened in a follow-up commit.

Real behavior proof

Behavior or issue addressed: With this PR at head 654cf20737, stripFilenameControlChars (src/media/outbound-filename.ts) is invoked at the four documented call sites in src/media/fetch.ts:73,75,80 (Content-Disposition filename* / filename branches) and src/media/web-media.ts:556 (URL pathname basename), removing C0/C1 controls (U+0000–U+001F, U+007F–U+009F), zero-width characters (U+200B–U+200D, U+FEFF), and bidi override + isolate formatting (U+202A–U+202E, U+2066–U+2069) from outbound filename strings before they are surfaced to recipient client UI or used as on-disk filenames. This is defense-in-depth for display labels — file bytes, MIME inference, and channel delivery are unchanged.

Real environment tested: Local Node v22.22.2 with the tsx loader (the same TS-on-Node mechanism the repository uses in its own scripts, e.g. node --import tsx scripts/check-import-cycles.ts). Worktree at HEAD 654cf20737 (this PR's head). The harness imports stripFilenameControlChars from src/media/outbound-filename.ts — the same exported function src/media/fetch.ts:11 and src/media/web-media.ts:31 import — and invokes it with the same stripFilenameControlChars(path.basename(input)) call shape that src/media/fetch.ts:80 uses, against adversarial input strings. The harness does not exercise the surrounding HTTP fetch, channel adapter, or runtime delivery code — only the helper itself, as imported.

Exact steps or command run after this patch:

git checkout 654cf20737
node --import tsx --input-type=module <<'PROOF'
import { stripFilenameControlChars } from "./src/media/outbound-filename.ts";
import path from "node:path";

const RLO  = String.fromCodePoint(0x202e);
const LRI  = String.fromCodePoint(0x2068);
const PDI  = String.fromCodePoint(0x2069);
const ZWSP = String.fromCodePoint(0x200b);
const BOM  = String.fromCodePoint(0xfeff);
const NEL  = String.fromCodePoint(0x0085);

const cases = [
  { name: "baseline ASCII (must passthrough unchanged)", input: "invoice.pdf" },
  { name: "RLO bidi override U+202E in basename",        input: "inv" + RLO + "oice.pdf" },
  { name: "CRLF header-injection vector (U+000D U+000A)", input: "inv\r\nX-Injected: yes\r\n.pdf" },
  { name: "ZWSP U+200B interspersed",                    input: "in" + ZWSP + "voice.pdf" },
  { name: "BOM U+FEFF prefix",                           input: BOM + "invoice.pdf" },
  { name: "C1 control NEL U+0085",                       input: "inv" + NEL + "oice.pdf" },
  { name: "LRI+PDI isolate U+2068 U+2069",               input: LRI + "inv" + PDI + "oice.pdf" },
];
const hex = (s) => Buffer.from(s, "utf8").toString("hex").match(/.{2}/g).join(" ");

console.log("// stripFilenameControlChars roundtrip -- PR 72973 head 654cf20737");
console.log("// helper source: src/media/outbound-filename.ts (exported, imported by fetch.ts:11 and web-media.ts:31)");
console.log("// the harness below applies the same stripFilenameControlChars(path.basename(input)) call shape that fetch.ts:80 uses, against adversarial input strings.");
console.log("// Node version: " + process.version);
console.log("");

let pass = 0;
let fail = 0;
for (const c of cases) {
  const after = stripFilenameControlChars(path.basename(c.input.trim()));
  const inHex = hex(c.input);
  const outHex = hex(after);
  const isBaseline = c.input === "invoice.pdf";
  const ok = isBaseline ? after === "invoice.pdf" : after !== c.input;
  if (ok) { pass += 1; } else { fail += 1; }
  console.log("[case] " + c.name);
  console.log("  in_chars  " + c.input.length + " utf16 / hex utf8: " + inHex);
  console.log("  out_chars " + after.length + " utf16 / hex utf8: " + outHex);
  console.log("  out_repr  " + JSON.stringify(after));
  console.log("  verdict   " + (ok ? "OK (no invisible chars remain)" : "FAIL"));
  console.log("");
}
console.log("// summary: " + pass + "/" + (pass + fail) + " passed");
PROOF

Evidence after fix: Verbatim stdout captured 2026-05-17 from running the command above against worktree HEAD 654cf20737:

// stripFilenameControlChars roundtrip -- PR 72973 head 654cf20737
// helper source: src/media/outbound-filename.ts (exported, imported by fetch.ts:11 and web-media.ts:31)
// the harness below applies the same stripFilenameControlChars(path.basename(input)) call shape that fetch.ts:80 uses, against adversarial input strings.
// Node version: v22.22.2

[case] baseline ASCII (must passthrough unchanged)
  in_chars  11 utf16 / hex utf8: 69 6e 76 6f 69 63 65 2e 70 64 66
  out_chars 11 utf16 / hex utf8: 69 6e 76 6f 69 63 65 2e 70 64 66
  out_repr  "invoice.pdf"
  verdict   OK (no invisible chars remain)

[case] RLO bidi override U+202E in basename
  in_chars  12 utf16 / hex utf8: 69 6e 76 e2 80 ae 6f 69 63 65 2e 70 64 66
  out_chars 11 utf16 / hex utf8: 69 6e 76 6f 69 63 65 2e 70 64 66
  out_repr  "invoice.pdf"
  verdict   OK (no invisible chars remain)

[case] CRLF header-injection vector (U+000D U+000A)
  in_chars  26 utf16 / hex utf8: 69 6e 76 0d 0a 58 2d 49 6e 6a 65 63 74 65 64 3a 20 79 65 73 0d 0a 2e 70 64 66
  out_chars 22 utf16 / hex utf8: 69 6e 76 58 2d 49 6e 6a 65 63 74 65 64 3a 20 79 65 73 2e 70 64 66
  out_repr  "invX-Injected: yes.pdf"
  verdict   OK (no invisible chars remain)

[case] ZWSP U+200B interspersed
  in_chars  12 utf16 / hex utf8: 69 6e e2 80 8b 76 6f 69 63 65 2e 70 64 66
  out_chars 11 utf16 / hex utf8: 69 6e 76 6f 69 63 65 2e 70 64 66
  out_repr  "invoice.pdf"
  verdict   OK (no invisible chars remain)

[case] BOM U+FEFF prefix
  in_chars  12 utf16 / hex utf8: ef bb bf 69 6e 76 6f 69 63 65 2e 70 64 66
  out_chars 11 utf16 / hex utf8: 69 6e 76 6f 69 63 65 2e 70 64 66
  out_repr  "invoice.pdf"
  verdict   OK (no invisible chars remain)

[case] C1 control NEL U+0085
  in_chars  12 utf16 / hex utf8: 69 6e 76 c2 85 6f 69 63 65 2e 70 64 66
  out_chars 11 utf16 / hex utf8: 69 6e 76 6f 69 63 65 2e 70 64 66
  out_repr  "invoice.pdf"
  verdict   OK (no invisible chars remain)

[case] LRI+PDI isolate U+2068 U+2069
  in_chars  13 utf16 / hex utf8: e2 81 a8 69 6e 76 e2 81 a9 6f 69 63 65 2e 70 64 66
  out_chars 11 utf16 / hex utf8: 69 6e 76 6f 69 63 65 2e 70 64 66
  out_repr  "invoice.pdf"
  verdict   OK (no invisible chars remain)

// summary: 7/7 passed

No secrets, tokens, chat identifiers, or user identifiers are involved — the harness operates entirely on hand-constructed adversarial filename strings.

Observed result after fix: For every adversarial input above, the helper's return value contains no invisible/control bytes. Concretely from the hex dumps in the Evidence section: (1) the RLO byte sequence e2 80 ae is removed; (2) the CRLF bytes 0d 0a are removed in both occurrences from the helper's return value (the value its callers in src/media/fetch.ts and src/media/web-media.ts then propagate as media.fileName); (3) the ZWSP / BOM / NEL / LRI+PDI byte sequences are removed; (4) the baseline ASCII filename invoice.pdf is preserved byte-for-byte (11 → 11 bytes, identical hex). The 7/7 passed line in the captured output indicates every reported verdict is OK. This proof exercises only the helper function — it does not observe an HTTP request, a Content-Disposition header on the wire, a runtime delivery, or any channel adapter execution.

What was not tested:

  • A full Telegram client → OpenClaw runtime → outbound Content-Disposition round-trip. Reason: it would require switching the local runtime symlink to this PR's worktree and a real-time Telegram client interaction, which would interrupt an unrelated active work item. The PR's extensions/ adapters consume media.fileName unchanged from the extraction sites this PR hardens, so the sanitized value reaches the adapter layer transparently. Happy to add a Telegram capture if a maintainer would like it as an additional artifact.
  • LRM / RLM / ALM directional marks (U+200E, U+200F, U+061C). Intentional, per the §Risks discussion on multilingual filename display.
  • Operator-supplied filename paths in extensions/discord/src/send.shared.ts and extensions/whatsapp/src/inbound/send-api.ts. Out of scope per §Scope boundary.

Outbound media filenames flow from two untrusted sources into channel adapters
(Telegram, Discord, Matrix, WhatsApp): HTTP `Content-Disposition` headers
parsed in `fetchRemoteMedia`, and URL pathnames extracted in
`loadWebMediaInternal`. Both paths previously called `path.basename(...)`
with no further sanitization, leaving C0/C1 control bytes, zero-width joiners,
and bidi formatting characters in the filename that downstream channels
forwarded verbatim.

This adds a `stripFilenameControlChars` helper in `src/media/outbound-filename.ts`
that removes the union of those invisible ranges (U+0000-U+001F, U+007F-U+009F,
U+200B-U+200D, U+202A-U+202E, U+2066-U+2069, U+FEFF) and applies it at the two
root extraction sites. All other Unicode is preserved, so legitimate Japanese,
Cyrillic, Arabic, etc. filenames pass through unchanged. Defense-in-depth: the
operating systems on the receiving end already enforce the real extension via
content sniffing, so this only closes the visual-display gap.

The new file is named to avoid colliding with the three existing
`sanitize{File,Filename}` helpers in `store.ts`, `file-context.ts`, and
`server.ts`, each of which has a different purpose (cross-platform path
safety, LLM prompt attribute escaping, HTTP header attachment escaping).
@masatohoshino masatohoshino marked this pull request as ready for review April 27, 2026 18:25
@aisle-research-bot

aisle-research-bot Bot commented Apr 27, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Incomplete removal of Unicode bidi marks enables filename UI spoofing (LRM/RLM/ALM not stripped)
1. 🟡 Incomplete removal of Unicode bidi marks enables filename UI spoofing (LRM/RLM/ALM not stripped)
Property Value
Severity Medium
CWE CWE-451
Location src/media/outbound-filename.ts:7-9

Description

stripFilenameControlChars is intended to remove invisible/formatting characters from untrusted filenames (from Content-Disposition and URL pathnames), specifically to prevent bidi-based spoofing. However, the regex omits several Unicode format characters that affect bidirectional rendering, including:

  • U+200E LEFT-TO-RIGHT MARK (LRM)
  • U+200F RIGHT-TO-LEFT MARK (RLM)
  • U+061C ARABIC LETTER MARK (ALM)

These characters are in Unicode category Cf and can influence how a filename is displayed in some clients/OS UIs while leaving the underlying byte sequence (and extension) unchanged. Because fetch.ts and web-media.ts now rely on stripFilenameControlChars(...) to produce the user-facing/propagated fileName, an attacker-controlled remote server can still deliver a confusing/spoofed filename by embedding these remaining bidi marks.

Vulnerable code:

const FILENAME_INVISIBLE_CONTROL_PATTERN =
  "[\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200D\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]";

Notably, the accompanying tests explicitly assert that U+200E/U+200F are preserved, which codifies the bypass.

Recommendation

Strip all bidi-affecting format marks from untrusted filenames.

A minimal targeted fix is to extend the character class to include LRM/RLM/ALM (and optionally other bidi marks):

const FILENAME_INVISIBLE_CONTROL_PATTERN =
  "[" +
  "\\u0000-\\u001F\\u007F-\\u009F" + // C0/C1
  "\\u061C" +                         // ALM
  "\\u200B-\\u200F" +                 // ZWSP..RLM (includes LRM/RLM)
  "\\u202A-\\u202E" +                 // embedding/overrides
  "\\u2066-\\u2069" +                 // isolates
  "\\uFEFF" +
  "]";

Alternatively (if runtime supports it), use Unicode property escapes to remove all control/format chars and then explicitly allow what you need, but be cautious about scope:

value.replace(/[\p{Cc}\p{Cf}]/gu, "")

Then add tests demonstrating that LRM/RLM/ALM are stripped and cannot be used for extension/UI spoofing.


Analyzed PR: #72973 at commit 654cf20

Last updated on: 2026-04-27T18:28:14Z

@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces src/media/outbound-filename.ts with a stripFilenameControlChars helper that removes C0/C1 controls, zero-width characters (U+200B–U+200D, U+FEFF), and bidi override/isolate formatting characters (U+202A–U+202E, U+2066–U+2069) from filenames before they reach channel UI document labels. The helper is applied at all three code paths in parseContentDispositionFileName (fetch.ts) and the URL-pathname extraction in loadWebMediaInternal (web-media.ts), with a 27-case unit test suite and one end-to-end integration case covering the RFC 5987 filename* decode path.

Confidence Score: 4/5

Safe to merge — pure helper applied at well-scoped call sites with no behavioral change to file content, MIME inference, or delivery paths.

Implementation is correct: the regex ranges match the declared strip set, the module-level g-flagged regex is only used with .replace() (safe; no shared lastIndex hazard), and the web-media.ts URL-path case is protected by the WHATWG URL constructor pre-encoding non-ASCII chars before path.basename is called. Test coverage is thorough (every strip range, boundary preserve characters, empty/all-control inputs, and an integration path through parseContentDispositionFileName). No P0 or P1 issues found.

No files require special attention.

Reviews (1): Last reviewed commit: "fix(media): strip control and bidi chara..." | Re-trigger Greptile

@masatohoshino

Copy link
Copy Markdown
Contributor Author

Thank you for the careful analysis.

I'd like to keep the current strip set for now and document the tradeoff, unless maintainers prefer the broader policy.

This PR targets outbound filename display hardening for visual-deception issues:

  • C0/C1 controls and zero-width characters that can hide or obscure filename text
  • Bidi override characters (U+202A-U+202E) and isolate characters (U+2066-U+2069) that can reorder text segments and are the stronger controls relevant to RTLO-style filename deception and Trojan-Source-style visual reordering

LRM (U+200E), RLM (U+200F), and ALM (U+061C) are also bidi-affecting invisible format characters, but they are directional marks rather than override/isolate controls. They are used legitimately in Arabic, Hebrew, and other RTL-script contexts to resolve weak-direction characters such as digits and punctuation in mixed-script text.

Because OpenClaw delivers files across multilingual chat surfaces, stripping those marks may cause legitimate mixed-script filenames to render with degraded directionality for RTL-script users. That would be an irreversible display regression at this filename extraction boundary.

The current strip set therefore intentionally targets the stronger override/isolate controls used for visual reordering attacks, while preserving directional marks whose security benefit from stripping appears more theoretical and whose i18n cost is concrete.

I'm happy to widen the strip set if maintainers prefer a stricter policy for all bidi-affecting invisible characters at this boundary, but I wanted to make the multilingual filename tradeoff explicit before doing so.

@clawsweeper

clawsweeper Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs real behavior proof before merge.

Summary
The PR adds a shared outbound media filename sanitizer, applies it to Content-Disposition and local media filename extraction, and adds focused media tests.

Reproducibility: yes. Source inspection shows current main returns Content-Disposition and local media basenames without filtering the reported invisible/control characters; a focused fetchRemoteMedia or loadWebMedia harness can reproduce the filename value without live channels.

Real behavior proof
Needs stronger real behavior proof before merge: The PR now includes terminal output for the helper, but it does not exercise Content-Disposition, loadWebMedia, or Telegram delivery; the contributor should add redacted runtime output, logs, linked artifacts, or a short recording and update the PR body for re-review.

Next step before merge
Needs human/contributor handling because the branch is dirty, proof is still boundary-insufficient, and the remaining bidi mark scope is a security/i18n policy decision.

Security
Cleared: No dependency, permission, secret, lifecycle, or code-execution surface is added; the remaining security question is the filename bidi policy, not a supply-chain regression.

Review findings

  • [P2] Preserve basenameFromAnyPath on rebase — src/media/fetch.ts:70
Review details

Best possible solution:

Land a rebased media-boundary sanitizer that wraps basenameFromAnyPath/extnameFromAnyPath, records the agreed bidi policy in tests, and proves the actual fetch/local-media path or Telegram label.

Do we have a high-confidence way to reproduce the issue?

Yes. Source inspection shows current main returns Content-Disposition and local media basenames without filtering the reported invisible/control characters; a focused fetchRemoteMedia or loadWebMedia harness can reproduce the filename value without live channels.

Is this the best way to solve the issue?

No, not as currently mergeable. Centralizing the strip at the media filename boundary is the right shape, but the branch must preserve current basenameFromAnyPath behavior and settle proof plus bidi policy first.

Full review comments:

  • [P2] Preserve basenameFromAnyPath on rebase — src/media/fetch.ts:70
    Current main fixed untrusted media filenames to drop Windows-style path components with basenameFromAnyPath. This branch still wraps path.basename/path.extname at the same extraction sites, so rebasing it that way would reintroduce backslash-delimited names into outbound labels; keep basenameFromAnyPath/extnameFromAnyPath before applying the new strip helper.
    Confidence: 0.84

Overall correctness: patch is incorrect
Overall confidence: 0.84

Acceptance criteria:

  • node scripts/run-vitest.mjs src/media/outbound-filename.test.ts src/media/fetch.test.ts src/media/web-media.test.ts
  • node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"
  • Telegram visible proof via telegram-crabbox-e2e-proof or equivalent redacted runtime proof

What I checked:

  • Current main remote filename path lacks this filter: parseContentDispositionFileName returns basenameFromAnyPath(decodeURIComponent(encoded)) or basenameFromAnyPath(encoded) with no stripFilenameControlChars or equivalent outbound display-label filter. (src/media/fetch.ts:120, 421b9e281942)
  • Current main local media filename path lacks this filter: loadWebMediaInternal derives fileName from basenameFromAnyPath(mediaUrl) and may append an extension, but does not strip C0/C1, zero-width, or bidi formatting characters before returning media.fileName. (src/media/web-media.ts:619, 421b9e281942)
  • No current-main outbound sanitizer exists: Searching current main found no stripFilenameControlChars, outbound-filename module, or FILENAME_INVISIBLE_CONTROL symbol under src/media or extensions. (421b9e281942)
  • PR head adds the intended helper but wraps stale basename calls: The live PR diff adds src/media/outbound-filename.ts and applies stripFilenameControlChars to path.basename calls in fetch.ts and web-media.ts, which no longer matches current main's basenameFromAnyPath helpers. (src/media/fetch.ts:70, 654cf20737f3)
  • Current main has cross-platform basename helpers: basenameFromAnyPath composes path.win32.basename(path.posix.basename(value)), which is the current-main behavior the PR must preserve when rebased. (src/media/file-name.ts:3, 421b9e281942)
  • History ties the current basename behavior to media filename work: Commit ca7349b is titled fix(media): normalize cross-platform media paths and is the relevant current-main provenance for the basename helper behavior. (src/media/file-name.ts:3, ca7349b585dc)

Likely related people:

  • vincentkoc: Authored the current cross-platform media path normalization that this PR must preserve, with adjacent web-media seam history. (role: recent media filename contributor; confidence: high; commits: ca7349b585dc, 73539ac78720; files: src/media/file-name.ts, src/media/fetch.ts, src/media/web-media.ts)
  • steipete: Current blame and history show Peter Steinberger on web-media filename lines and earlier media/reply helper consolidation around the same delivery path. (role: recent media area contributor; confidence: medium; commits: b328f57bc3f7, 4075895c4ca4; files: src/media/web-media.ts, src/media/fetch.ts, extensions/telegram/src/bot/delivery.replies.ts)
  • shakkernerd: Recently changed src/media/fetch.ts for bodyless media response buffering, making them relevant for fetch-path review and validation. (role: recent media fetch contributor; confidence: medium; commits: 433bafa55bc7; files: src/media/fetch.ts, src/media/fetch.test.ts)

Remaining risk / open question:

  • The PR head is dirty/conflicting and its diff still wraps path.basename/path.extname, so a naive rebase could regress current Windows-style basename normalization.
  • The supplied terminal proof exercises the helper directly, not the Content-Disposition, loadWebMedia, or live channel boundary that users observe.
  • Maintainers still need to decide whether preserving LRM/RLM/ALM is the intended security/i18n policy for untrusted outbound filenames.
  • Open filename work in media: add shared filename decoder #71517 and fix(whatsapp): strip control characters from outbound document fileName #77114 may affect landing order.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 421b9e281942.

@clawsweeper clawsweeper Bot mentioned this pull request May 1, 2026
20 tasks
@clawsweeper clawsweeper Bot added the mantis: telegram-visible-proof Mantis should capture Telegram visible proof. label May 14, 2026
@openclaw-barnacle openclaw-barnacle Bot added triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 14, 2026
@clawsweeper clawsweeper Bot added P2 Normal backlog priority with limited blast radius. impact:security Security boundary, credential, authz, sandbox, or sensitive-data risk. labels May 17, 2026
@masatohoshino

Copy link
Copy Markdown
Contributor Author

Closing this PR to reduce maintainer review load.

The original shared outbound filename-sanitizer idea is still useful, but this PR is no longer in a strong review state:

  • proof: sufficient is no longer present
  • Real-behavior-proof CI has failed on recent runs
  • mantis: telegram-visible-proof remains unresolved
  • the branch has accumulated conflicts

The urgent per-channel surfaces are now better represented by narrower PRs:

The remaining shared sanitizer / bidi-marker policy surface is better revisited later if a concrete visible reproduction appears, especially for Telegram or another affected adapter.

Closing to reduce shelf noise. I can re-submit a narrower shared-helper PR later if there is maintainer interest or a concrete channel proof.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

impact:security Security boundary, credential, authz, sandbox, or sensitive-data risk. mantis: telegram-visible-proof Mantis should capture Telegram visible proof. P2 Normal backlog priority with limited blast radius. proof: supplied External PR includes structured after-fix real behavior proof. size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant