Repository navigation
fix(security): close Discord-attachment RCE via filename → fast-path shell injection - #1022
Merged
sonichi merged 2 commits intoMay 22, 2026
Merged
Conversation
…shell injection
## The bug (security — RCE)
Two-stage chain that lets a Discord user achieve arbitrary code
execution on the owner's machine:
### Stage 1: discord-bridge saves attachment filenames verbatim
`src/discord-bridge.py` (two sites):
local_path = INBOX_DIR / f"{int(time.time()*1000)}_{att.filename}"
Discord allows arbitrary filenames: spaces, quotes, semicolons,
backticks, `$`, `..`. An attacker uploads:
x"; touch /tmp/pwn; #.jpg
The bridge saves it as
/tmp/discord-inbox/1779392000000_x"; touch /tmp/pwn; #.jpg
### Stage 2: conversation-server fast path shell-splices the path
`skills/phone-conversation/scripts/conversation-server.ts:310`:
const image = execSync('ls -t /tmp/discord-inbox/*.jpg ...')
.toString().trim();
const result = execSync(
`bash .../prepend-image.sh "${image}" "${video}" 3`, ...);
When the owner is on a phone call and says "prepend image to video"
(triggers the concat fast path), the `image` glob picks up the
malicious filename. execSync uses `/bin/sh -c`:
bash .../prepend-image.sh "/tmp/discord-inbox/.._x"; touch /tmp/pwn; #.jpg" "..." 3
The first arg to bash is `"/tmp/discord-inbox/..._x"` — closing `"`
ends the argument. Then `; touch /tmp/pwn` runs, then `; #.jpg" ...`
is a comment.
Concrete RCE from a Discord DM attachment + a single voice trigger
from the owner.
## Fix — two layers
### Layer A: sanitize at the boundary
`src/discord-bridge.py`: new `_safe_attachment_basename()` helper.
Mirrors `_safe_id` from `src/agent-api.py` — keep alphanumerics +
`._-`; replace everything else with `_`; strip path-traversal;
preserve short extensions for glob matching; cap length to bound
DoS via multi-kilobyte filenames. Applied at both `att.filename`
save sites.
### Layer B: defense-in-depth at the use
`skills/phone-conversation/scripts/conversation-server.ts`: switch
the prepend-image fast path from
execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`)
to
const scriptPath = `${process.env.HOME}/.claude/skills/video-concat/scripts/prepend-image.sh`;
execFileSync('bash', [scriptPath, image, video, '3'], ...);
`execFileSync` bypasses `/bin/sh -c`; each argv entry is a separate
arg to `bash` so no value spliced into argv gets interpreted as
shell syntax. Closes the injection class regardless of what the
filename contains.
Also expands `~` to `$HOME` (execFileSync doesn't run via shell, so
`~` wouldn't be expanded).
## Tests
8 cases in `tests/discord-bridge-attachment-filename-sanitize.test.py`:
- normal filenames preserved
- shell-injection chars stripped
- backtick + `$` stripped
- path-traversal stripped
- empty/pure-special → fallback name
- common extensions preserved (glob matching)
- Unicode replaced
- overlong filename capped
3 cases in `tests/conversation-server-fastpath-execfile.test.ts`:
- execFileSync imported
- execFileSync('bash', [...]) pattern present
- raw execSync(`bash ... "${image}"`) pattern absent
All 11 pass locally. `npm run typecheck` clean. Python syntax clean.
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>
2 of 3 tasks
liususan091219
approved these changes
May 22, 2026
liususan091219
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed the Discord-attachment RCE fix. The two-stage chain is correctly diagnosed and the two-layer fix closes it at both ends — ready to merge.
Things I like:
- Defense at the boundary AND at the use. Either layer alone would close the RCE, but doing both is the right call:
_safe_attachment_basename()(discord-bridge.py) means no dangerous filename ever lands in/tmp/discord-inbox/for any consumer, and theexecFileSyncswitch (conversation-server.ts:~310) protects the fast path even if a future write site forgets to sanitize. _safe_attachment_basenameis carefully built — allowlist[a-zA-Z0-9_\-.],.strip("._")to kill leading..traversal, length cap, and extension-preservation so the downstream*.jpg/*.pngglobs keep matching legitimate uploads. I traced../../../etc/passwdthrough it:/→_, leading dotted run stripped →etc_passwd, no slash, no..— matches the test's assertions.- Mirroring
_safe_idfromagent-api.pykeeps the sanitizer shape consistent across the codebase's two attacker-facing ingest points — one mental model, not two. - The fast-path fix correctly handles the no-shell consequence:
~doesn't expand underexecFileSync, so the script path is rebuilt as${process.env.HOME}/.claude/.... Easy detail to miss; the PR got it right. - Both attachment save sites in
discord-bridge.py(the embed-snapshot loop ~2335 and the main-message loop ~2391) are patched — not just the one in the headline example.
Non-blocking observations:
- The
.pytest actually executes_safe_attachment_basenameagainst real adversarial inputs (quotes, backticks,$IFS, traversal, unicode, 10KB name) — genuine behavioral coverage, stronger than the source-grep guards. The.tsside is source-grep (consistent with the repo convention). _safe_attachment_basenamecollapses every disallowed char to_, so two distinct uploads (a b.jpg,a-b.jpg) can sanitize to the same basename — but theint(time.time()*1000)_timestamp prefix already disambiguates, so no collision in practice. Just noting it isn't an injective transform.
Cross-PR: conversation-server.ts is also touched by #1019 (the /meeting handler) and #1018 (config path) in different regions — no textual conflict expected with this PR's import line + fast-path change, but second-to-merge should re-run typecheck.
Tests: 8 behavioral py assertions + 3 ts guards; the PR body reports all passing.
Correctly diagnosed RCE, fixed at both the boundary and the use, with real behavioral tests on the sanitizer. LGTM.
— Lucy (Mac Studio bot)
Closed
4 tasks
Merged
2 of 4 tasks
2 of 3 tasks
sonichi
added a commit
that referenced
this pull request
May 22, 2026
…stall CI The test added by #1022 stubbed only `os.environ["DISCORD_BOT_TOKEN"]`, but `src/discord-bridge.py` reads the token from `~/.claude/channels/discord/.env` (FILE path), not from the env var, and `exit(1)`s at module load when the file is missing. CI has no such file → the test crashed before any assertion ran (npm test:py exit 1). Stub HOME to a temp dir + write a fake `.env` with the token. The module then loads cleanly without requiring a real bot credential in CI. Test logic itself is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi
added a commit
that referenced
this pull request
May 22, 2026
…sting (#1042) * fix(tests): restore main CI — _isVoiceTask fixture order + import hoisting Two post-#1023 test-only fixes; main has been CI-red since #1023 merged. 1. tests/task-bridge-isvoice.test.ts — VOICE_BODY / NON_VOICE_BODY had `task:` first, headers after. Pre-#1023 the reader used `.some()` over all lines so this worked; post-#1023 the reader stops at `task:` (closes the body-forging vector @qingyun-wu flagged), so any header AFTER `task:` is invisible. Reordered fixtures to put `task:` last, matching the writer convention #1023 enforces on all three task-bridge writers. 2. tests/task-bridge-stop-at-task-delimiter.test.ts — set SUTANDO_WORKSPACE = TMP at the top of the file, then statically imported task-bridge. ES-module `import` statements are hoisted per spec — the static import ran BEFORE the env-set, so resolveWorkspace() captured the ambient workspace at module load and `_isVoiceTask` looked there instead of TMP. Tests expecting `true` failed (file not where the function looked); tests expecting `false` passed by accident (file-not-found → false). Switched to top-level `await import(...)` so the import runs in source order, after the env is set. Verified: typecheck clean; 13/13 cases in both files pass; full suite goes from 7 failing → 0 failing on these two suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): stub HOME so discord-bridge module load works on clean-install CI The test added by #1022 stubbed only `os.environ["DISCORD_BOT_TOKEN"]`, but `src/discord-bridge.py` reads the token from `~/.claude/channels/discord/.env` (FILE path), not from the env var, and `exit(1)`s at module load when the file is missing. CI has no such file → the test crashed before any assertion ran (npm test:py exit 1). Stub HOME to a temp dir + write a fake `.env` with the token. The module then loads cleanly without requiring a real bot credential in CI. Test logic itself is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): task-priority fixture order — task: last, per #1023 `tests/task-priority.test.py:test_parse_priority_from_text_finds_header` had `task:` on line 3 with `priority: urgent` on line 5. PR #1023 added `task:` to `parse_priority_from_text`'s break condition (closing the body-forging vector @qingyun-wu flagged on #991), so `priority:` lines AFTER `task:` are now treated as body content — the test flipped from pass → fail in CI when #1023 merged. Reorder the fixture: `task:` last, header fields before. Test logic unchanged. Same class of fixture-order bug as the two test files already fixed in this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 of 4 tasks
sonichi
added a commit
that referenced
this pull request
May 25, 2026
…achments (#1029) * feat(dm-result): REST multipart upload — actually deliver [file:] attachments ## Why — closes the follow-up flagged in PR #985 PR #985 stripped `[file:|send:|attach:]` markers from the dm-result body before sending so attackers couldn't deliver the literal text `[file: /path]` to the user's DM. But that left a real gap: files that legitimately accompany a result body (screenshots, briefings, generated docs) were dropped entirely on the REST-only fallback path. The TODO comment on the strip site explicitly called out: "REST multipart upload for actual file delivery is a follow-up" This PR implements that follow-up. The WS-connected live bridge calls `discord.File(path)` for each marker; this REST path now builds the equivalent `multipart/form-data` POST by hand using only stdlib `urllib.request` (consistent with the rest of dm-result.py's zero-dependency posture). ## What ships ### 1. Path allowlist (mirrors discord-bridge) `_SEND_ALLOWED_ROOTS` + `_SEND_ALLOWED_PREFIXES` + `_is_path_sendable` — byte-identical policy to the canonical helper in `src/discord-bridge.py`. Keeping the policy in sync means a future allowlist tightening in either path doesn't drift the other. ### 2. Multipart upload helper `_send_message_with_files(channel_id, token, content, file_paths)` constructs the multipart envelope: - random boundary via uuid4 (won't collide with payload) - `payload_json` part for the message body (`{"content": "..."}`) - `files[N]` parts with `filename=` and binary content - 30s timeout (multipart uploads can be slower than JSON; bound the invocation time) Filename header sanitization mirrors PR #1022's `_safe_attachment_basename` shape — CR/LF/`"` in basenames are replaced with `_`, length capped at 80 chars. Without this, a file whose basename contains `"\r\nContent-Disposition: form-data; name="evil"; filename="exfil` could inject its own headers into the multipart envelope. ### 3. send_dm() updated Splits the file markers into `sendable` (passes allowlist) vs `rejected` (logged to stderr, not uploaded). Sends text chunks first via the existing JSON path, then attaches files in batches of 10 (Discord's per-message attachment cap) as separate multipart POSTs. Backwards-compatible: a body with no markers goes through unchanged. ## Tests `tests/dm-result-multipart-upload.test.py` (5 cases) — replaces `urllib.request.urlopen` with `_FakeTransport` that captures the full request body bytes so the test can assert on the actual multipart envelope shape: - allowlisted file uploads via multipart (file bytes present) - non-allowlisted file rejected (zero multipart calls) - file-only message (empty content but valid multipart) - 11 files split into 10+1 batches - filename header sanitization (CR/LF/`"` stripped — defends against multipart-envelope splicing via attacker-supplied filename, same class as PR #1022) All 5 pass locally. Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> * refactor(send-allowlist): extract shared module per @liususan091219 review Lucy's PR #1029 review flagged the file-attachment allowlist as a copy between discord-bridge.py and dm-result.py, with a "keep in sync" comment that wouldn't prevent drift — and noted that copies "will drift" regardless. Per her recommendation: extract to a tiny shared module that both consumers import. ## Pre-existing drift this also fixes The two copies were already out of sync: - `src/discord-bridge.py` had the richer set: `_REPO / "results"`, `_REPO / "notes"`, `shared_personal_path("notes", _REPO)`, `_REPO / "docs"`, `~/Desktop/iclr-backups`, `~/Documents/sutando-launch-assets`, plus the four `/tmp/*` prefixes. - `src/dm-result.py` (from PR #1029) had the narrower set: only `_REPO / "results"`, `_REPO / "notes"`, `_REPO / "docs"`, and the same four prefixes — missing the personal-notes / Desktop / Documents roots. So a file like `~/Desktop/iclr-backups/asset.png` would deliver via the live WS bridge but get rejected by the REST fallback. Same shape as the kinds of drift Lucy was guarding against. The extract makes the policy a single set; both paths now respect the same allowlist. ## What ships ### `src/send_allowlist.py` (new) - `SEND_ALLOWED_ROOTS` — the canonical superset (all 6 roots). - `SEND_ALLOWED_PREFIXES` — the four `/tmp/*` prefixes. - `is_path_sendable(fpath) -> bool` — the canonical predicate. Imports `resolve_workspace` and `shared_personal_path` at module- load so the per-user paths resolve correctly. Stays dep-light (stdlib only beyond those two existing helpers). ### `src/discord-bridge.py` Replaces 21-line inline definition + 22-line function with an import + a one-line alias (`_is_path_sendable = _is_path_sendable_shared`). The 7 existing call sites in this file don't need touching because the local name `_is_path_sendable` is preserved. ### `src/dm-result.py` Replaces the PR #1029 inline copy with the same import. Local names `_SEND_ALLOWED_ROOTS` / `_SEND_ALLOWED_PREFIXES` preserved via import-as alias so other tests that reference them keep working. ## Tests `tests/send-allowlist-shared.test.py` — 6 architectural assertions: - shared module exists - discord-bridge.py imports from it - dm-result.py imports from it - no inline (re-)definitions of `SEND_ALLOWED_ROOTS` / `SEND_ALLOWED_PREFIXES` in either consumer - no inline `def [_]is_path_sendable(` in either consumer - the shared module contains the documented set (drops are deliberate, pinned by this test) The architectural pin is the load-bearing piece: it makes future "oh I'll just add it inline real quick" regressions fail loudly. All 6 tests pass locally. Existing `tests/dm-result-multipart-upload.test.py` (5 cases) and `tests/dm-result-send-dm.test.py` (5 cases) still pass — no behavior change for the live paths, just plumbing. Co-Authored-By: liususan091219 (review feedback) Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> * test(discord-bridge-allowlist): accept send_allowlist.py as helper location Per @qingyun-wu PR #1029 review — CI red because the structural test in `tests/discord-bridge-allowlist.test.py` greps for `def _is_path_sendable(...)` in `src/discord-bridge.py`. The PR moves the canonical implementation into `src/send_allowlist.py` (per @liususan091219's drift-extraction review on #1029) and leaves only a runtime alias in discord-bridge.py, so the original regex misses. Fix: scan BOTH source files. Helper is found in either location: - Legacy inline form: `def _is_path_sendable(...)` in discord-bridge.py - Post-refactor form: `def is_path_sendable(...)` in send_allowlist.py If the helper lives in send_allowlist.py, the test additionally verifies discord-bridge.py imports it — without the import, the gate sites (`discord.File(fpath)`) would reference an undefined name. DOTALL match so multi-line `from send_allowlist import (… as …, ...)` parenthesized imports work. The contract this test guards (helper uses os.path.realpath for CodeQL sanitizer, checks both ROOTS and PREFIXES, fail-closed default, all sinks gated) is unchanged — only the location-discovery logic was updated. Co-Authored-By: qingyun-wu (review feedback) Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> --------- Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
liususan091219
pushed a commit
to liususan091219/sutando-fork
that referenced
this pull request
May 28, 2026
…shell injection (sonichi#1022) ## The bug (security — RCE) Two-stage chain that lets a Discord user achieve arbitrary code execution on the owner's machine: ### Stage 1: discord-bridge saves attachment filenames verbatim `src/discord-bridge.py` (two sites): local_path = INBOX_DIR / f"{int(time.time()*1000)}_{att.filename}" Discord allows arbitrary filenames: spaces, quotes, semicolons, backticks, `$`, `..`. An attacker uploads: x"; touch /tmp/pwn; #.jpg The bridge saves it as /tmp/discord-inbox/1779392000000_x"; touch /tmp/pwn; #.jpg ### Stage 2: conversation-server fast path shell-splices the path `skills/phone-conversation/scripts/conversation-server.ts:310`: const image = execSync('ls -t /tmp/discord-inbox/*.jpg ...') .toString().trim(); const result = execSync( `bash .../prepend-image.sh "${image}" "${video}" 3`, ...); When the owner is on a phone call and says "prepend image to video" (triggers the concat fast path), the `image` glob picks up the malicious filename. execSync uses `/bin/sh -c`: bash .../prepend-image.sh "/tmp/discord-inbox/.._x"; touch /tmp/pwn; #.jpg" "..." 3 The first arg to bash is `"/tmp/discord-inbox/..._x"` — closing `"` ends the argument. Then `; touch /tmp/pwn` runs, then `; #.jpg" ...` is a comment. Concrete RCE from a Discord DM attachment + a single voice trigger from the owner. ## Fix — two layers ### Layer A: sanitize at the boundary `src/discord-bridge.py`: new `_safe_attachment_basename()` helper. Mirrors `_safe_id` from `src/agent-api.py` — keep alphanumerics + `._-`; replace everything else with `_`; strip path-traversal; preserve short extensions for glob matching; cap length to bound DoS via multi-kilobyte filenames. Applied at both `att.filename` save sites. ### Layer B: defense-in-depth at the use `skills/phone-conversation/scripts/conversation-server.ts`: switch the prepend-image fast path from execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`) to const scriptPath = `${process.env.HOME}/.claude/skills/video-concat/scripts/prepend-image.sh`; execFileSync('bash', [scriptPath, image, video, '3'], ...); `execFileSync` bypasses `/bin/sh -c`; each argv entry is a separate arg to `bash` so no value spliced into argv gets interpreted as shell syntax. Closes the injection class regardless of what the filename contains. Also expands `~` to `$HOME` (execFileSync doesn't run via shell, so `~` wouldn't be expanded). ## Tests 8 cases in `tests/discord-bridge-attachment-filename-sanitize.test.py`: - normal filenames preserved - shell-injection chars stripped - backtick + `$` stripped - path-traversal stripped - empty/pure-special → fallback name - common extensions preserved (glob matching) - Unicode replaced - overlong filename capped 3 cases in `tests/conversation-server-fastpath-execfile.test.ts`: - execFileSync imported - execFileSync('bash', [...]) pattern present - raw execSync(`bash ... "${image}"`) pattern absent All 11 pass locally. `npm run typecheck` clean. Python syntax clean. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
liususan091219
pushed a commit
to liususan091219/sutando-fork
that referenced
this pull request
May 28, 2026
…sting (sonichi#1042) * fix(tests): restore main CI — _isVoiceTask fixture order + import hoisting Two post-sonichi#1023 test-only fixes; main has been CI-red since sonichi#1023 merged. 1. tests/task-bridge-isvoice.test.ts — VOICE_BODY / NON_VOICE_BODY had `task:` first, headers after. Pre-sonichi#1023 the reader used `.some()` over all lines so this worked; post-sonichi#1023 the reader stops at `task:` (closes the body-forging vector @qingyun-wu flagged), so any header AFTER `task:` is invisible. Reordered fixtures to put `task:` last, matching the writer convention sonichi#1023 enforces on all three task-bridge writers. 2. tests/task-bridge-stop-at-task-delimiter.test.ts — set SUTANDO_WORKSPACE = TMP at the top of the file, then statically imported task-bridge. ES-module `import` statements are hoisted per spec — the static import ran BEFORE the env-set, so resolveWorkspace() captured the ambient workspace at module load and `_isVoiceTask` looked there instead of TMP. Tests expecting `true` failed (file not where the function looked); tests expecting `false` passed by accident (file-not-found → false). Switched to top-level `await import(...)` so the import runs in source order, after the env is set. Verified: typecheck clean; 13/13 cases in both files pass; full suite goes from 7 failing → 0 failing on these two suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): stub HOME so discord-bridge module load works on clean-install CI The test added by sonichi#1022 stubbed only `os.environ["DISCORD_BOT_TOKEN"]`, but `src/discord-bridge.py` reads the token from `~/.claude/channels/discord/.env` (FILE path), not from the env var, and `exit(1)`s at module load when the file is missing. CI has no such file → the test crashed before any assertion ran (npm test:py exit 1). Stub HOME to a temp dir + write a fake `.env` with the token. The module then loads cleanly without requiring a real bot credential in CI. Test logic itself is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): task-priority fixture order — task: last, per sonichi#1023 `tests/task-priority.test.py:test_parse_priority_from_text_finds_header` had `task:` on line 3 with `priority: urgent` on line 5. PR sonichi#1023 added `task:` to `parse_priority_from_text`'s break condition (closing the body-forging vector @qingyun-wu flagged on sonichi#991), so `priority:` lines AFTER `task:` are now treated as body content — the test flipped from pass → fail in CI when sonichi#1023 merged. Reorder the fixture: `task:` last, header fields before. Test logic unchanged. Same class of fixture-order bug as the two test files already fixed in this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219
pushed a commit
to liususan091219/sutando-fork
that referenced
this pull request
May 28, 2026
…achments (sonichi#1029) * feat(dm-result): REST multipart upload — actually deliver [file:] attachments ## Why — closes the follow-up flagged in PR sonichi#985 PR sonichi#985 stripped `[file:|send:|attach:]` markers from the dm-result body before sending so attackers couldn't deliver the literal text `[file: /path]` to the user's DM. But that left a real gap: files that legitimately accompany a result body (screenshots, briefings, generated docs) were dropped entirely on the REST-only fallback path. The TODO comment on the strip site explicitly called out: "REST multipart upload for actual file delivery is a follow-up" This PR implements that follow-up. The WS-connected live bridge calls `discord.File(path)` for each marker; this REST path now builds the equivalent `multipart/form-data` POST by hand using only stdlib `urllib.request` (consistent with the rest of dm-result.py's zero-dependency posture). ## What ships ### 1. Path allowlist (mirrors discord-bridge) `_SEND_ALLOWED_ROOTS` + `_SEND_ALLOWED_PREFIXES` + `_is_path_sendable` — byte-identical policy to the canonical helper in `src/discord-bridge.py`. Keeping the policy in sync means a future allowlist tightening in either path doesn't drift the other. ### 2. Multipart upload helper `_send_message_with_files(channel_id, token, content, file_paths)` constructs the multipart envelope: - random boundary via uuid4 (won't collide with payload) - `payload_json` part for the message body (`{"content": "..."}`) - `files[N]` parts with `filename=` and binary content - 30s timeout (multipart uploads can be slower than JSON; bound the invocation time) Filename header sanitization mirrors PR sonichi#1022's `_safe_attachment_basename` shape — CR/LF/`"` in basenames are replaced with `_`, length capped at 80 chars. Without this, a file whose basename contains `"\r\nContent-Disposition: form-data; name="evil"; filename="exfil` could inject its own headers into the multipart envelope. ### 3. send_dm() updated Splits the file markers into `sendable` (passes allowlist) vs `rejected` (logged to stderr, not uploaded). Sends text chunks first via the existing JSON path, then attaches files in batches of 10 (Discord's per-message attachment cap) as separate multipart POSTs. Backwards-compatible: a body with no markers goes through unchanged. ## Tests `tests/dm-result-multipart-upload.test.py` (5 cases) — replaces `urllib.request.urlopen` with `_FakeTransport` that captures the full request body bytes so the test can assert on the actual multipart envelope shape: - allowlisted file uploads via multipart (file bytes present) - non-allowlisted file rejected (zero multipart calls) - file-only message (empty content but valid multipart) - 11 files split into 10+1 batches - filename header sanitization (CR/LF/`"` stripped — defends against multipart-envelope splicing via attacker-supplied filename, same class as PR sonichi#1022) All 5 pass locally. Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> * refactor(send-allowlist): extract shared module per @liususan091219 review Lucy's PR sonichi#1029 review flagged the file-attachment allowlist as a copy between discord-bridge.py and dm-result.py, with a "keep in sync" comment that wouldn't prevent drift — and noted that copies "will drift" regardless. Per her recommendation: extract to a tiny shared module that both consumers import. ## Pre-existing drift this also fixes The two copies were already out of sync: - `src/discord-bridge.py` had the richer set: `_REPO / "results"`, `_REPO / "notes"`, `shared_personal_path("notes", _REPO)`, `_REPO / "docs"`, `~/Desktop/iclr-backups`, `~/Documents/sutando-launch-assets`, plus the four `/tmp/*` prefixes. - `src/dm-result.py` (from PR sonichi#1029) had the narrower set: only `_REPO / "results"`, `_REPO / "notes"`, `_REPO / "docs"`, and the same four prefixes — missing the personal-notes / Desktop / Documents roots. So a file like `~/Desktop/iclr-backups/asset.png` would deliver via the live WS bridge but get rejected by the REST fallback. Same shape as the kinds of drift Lucy was guarding against. The extract makes the policy a single set; both paths now respect the same allowlist. ## What ships ### `src/send_allowlist.py` (new) - `SEND_ALLOWED_ROOTS` — the canonical superset (all 6 roots). - `SEND_ALLOWED_PREFIXES` — the four `/tmp/*` prefixes. - `is_path_sendable(fpath) -> bool` — the canonical predicate. Imports `resolve_workspace` and `shared_personal_path` at module- load so the per-user paths resolve correctly. Stays dep-light (stdlib only beyond those two existing helpers). ### `src/discord-bridge.py` Replaces 21-line inline definition + 22-line function with an import + a one-line alias (`_is_path_sendable = _is_path_sendable_shared`). The 7 existing call sites in this file don't need touching because the local name `_is_path_sendable` is preserved. ### `src/dm-result.py` Replaces the PR sonichi#1029 inline copy with the same import. Local names `_SEND_ALLOWED_ROOTS` / `_SEND_ALLOWED_PREFIXES` preserved via import-as alias so other tests that reference them keep working. ## Tests `tests/send-allowlist-shared.test.py` — 6 architectural assertions: - shared module exists - discord-bridge.py imports from it - dm-result.py imports from it - no inline (re-)definitions of `SEND_ALLOWED_ROOTS` / `SEND_ALLOWED_PREFIXES` in either consumer - no inline `def [_]is_path_sendable(` in either consumer - the shared module contains the documented set (drops are deliberate, pinned by this test) The architectural pin is the load-bearing piece: it makes future "oh I'll just add it inline real quick" regressions fail loudly. All 6 tests pass locally. Existing `tests/dm-result-multipart-upload.test.py` (5 cases) and `tests/dm-result-send-dm.test.py` (5 cases) still pass — no behavior change for the live paths, just plumbing. Co-Authored-By: liususan091219 (review feedback) Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> * test(discord-bridge-allowlist): accept send_allowlist.py as helper location Per @qingyun-wu PR sonichi#1029 review — CI red because the structural test in `tests/discord-bridge-allowlist.test.py` greps for `def _is_path_sendable(...)` in `src/discord-bridge.py`. The PR moves the canonical implementation into `src/send_allowlist.py` (per @liususan091219's drift-extraction review on sonichi#1029) and leaves only a runtime alias in discord-bridge.py, so the original regex misses. Fix: scan BOTH source files. Helper is found in either location: - Legacy inline form: `def _is_path_sendable(...)` in discord-bridge.py - Post-refactor form: `def is_path_sendable(...)` in send_allowlist.py If the helper lives in send_allowlist.py, the test additionally verifies discord-bridge.py imports it — without the import, the gate sites (`discord.File(fpath)`) would reference an undefined name. DOTALL match so multi-line `from send_allowlist import (… as …, ...)` parenthesized imports work. The contract this test guards (helper uses os.path.realpath for CodeQL sanitizer, checks both ROOTS and PREFIXES, fail-closed default, all sinks gated) is unchanged — only the location-discovery logic was updated. Co-Authored-By: qingyun-wu (review feedback) Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com> --------- Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com> Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug (security — RCE)
Two-stage chain that lets a Discord user achieve arbitrary code execution on the owner's machine.
Stage 1: discord-bridge saves attachment filenames verbatim
src/discord-bridge.py(two sites, lines 2304 + 2357):```python
local_path = INBOX_DIR / f"{int(time.time()*1000)}_{att.filename}"
```
Discord allows arbitrary filenames: spaces, quotes, semicolons, backticks,
$,... An attacker uploadsx\"; touch /tmp/pwn; #.jpg. The bridge saves it as/tmp/discord-inbox/1779392000000_x\"; touch /tmp/pwn; #.jpg.Stage 2: conversation-server fast path shell-splices the path
skills/phone-conversation/scripts/conversation-server.ts:310:```ts
const image = execSync('ls -t /tmp/discord-inbox/*.jpg ...').toString().trim();
const result = execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`, ...);
```
When the owner is on a phone call and says "prepend image to video," the
imageglob picks up the malicious filename. execSync (/bin/sh -c) expansion:```bash
bash .../prepend-image.sh "/tmp/discord-inbox/.._x"; touch /tmp/pwn; #.jpg" "..." 3
```
The first arg to
bashis\"/tmp/discord-inbox/..._x\"; the closing\"ends the argument. Then; touch /tmp/pwnruns as a shell command, then; #.jpg\" ...is a comment.Concrete RCE from a Discord attachment + a single voice trigger from the owner.
Fix — two layers
Layer A: sanitize at the boundary
src/discord-bridge.py: new_safe_attachment_basename()helper. Mirrors_safe_idfromsrc/agent-api.py. Applied at both attachment save sites.Layer B: defense-in-depth at the use
skills/phone-conversation/scripts/conversation-server.ts: switch the fast path from```ts
execSync(`bash .../prepend-image.sh "${image}" "${video}" 3`)
```
to
```ts
const scriptPath = `${process.env.HOME}/.claude/skills/video-concat/scripts/prepend-image.sh`;
execFileSync('bash', [scriptPath, image, video, '3'], ...);
```
execFileSyncbypasses/bin/sh -c. Closes the injection class regardless of filename content.Tests
8 cases on the sanitizer, 3 cases on the execFileSync wiring. All 11 pass locally;
npm run typecheckclean; Python syntax clean.Test plan
python3 tests/discord-bridge-attachment-filename-sanitize.test.py— 8/8 passnpx tsx --test tests/conversation-server-fastpath-execfile.test.ts— 3/3 passpwn\"; touch /tmp/pwn-RCE; #.jpg; verify saved path has shell-meta chars stripped and/tmp/pwn-RCEdoes NOT appear after triggering the fast path🤖 Generated with Claude Code