[v0.1/P0] Harden OSC 8 activation and remove URI disclosure #13
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Total time spent: 2 seconds
Due date
natsukium
2 seconds
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#13
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P0.2)
Why
The grid parser filters OSC 8 schemes, but the final launcher still accepts an arbitrary string, logs producer-controlled URIs, and gives the user no safe target preview. That violates the documented activation and logging boundary.
Scope
ActivationTarget; platform launchers must not accept raw strings.xdg-open/openand the auditedShellExecuteWpath.Acceptance criteria
javascript:anddata:are rejected at the activation boundary even if stored by a future grid path.CHANGELOG.mdreflect the final behavior.Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12.Claim check
Accurate against HEAD (
69076d42). Nothing in the three post-snapshot commits touches this path.crates/felis-grid/src/lib.rs:2694-2713:sanitize_osc_strdrops any OSC body containing a C0 byte or DEL, andosc8_scheme_allowedallowlistshttp/https/mailto/file(ASCII case-insensitive). Nothing filters Unicode bidi controls (U+202A–U+202E, U+2066–U+2069, U+200E/F, U+061C are 0x80+ in UTF-8 and pass the C0/DEL check).crates/felis-client/src/hyperlink.rs:94pub fn open_url(url: &str)(Unix) and:124(Windows).ShellExecuteArgs::for_url(:150-158) does only the interior-NUL check viawide_z(:234-242). The Unix path has no check at all; it relies onCommand::arg, which rejects an interior NUL at spawn. No scheme re-check on either path.crates/felis-client/src/event_handler.rs:929-933:warn!(?err, %uri, "open URL failed")andinfo!(%uri, "opened OSC 8 hyperlink"). The Windows thread'swarn!(?err, ...)athyperlink.rs:128logs only theShellExecuteWcode, not the URI.event_handler.rs:1109-1117,main.rs:1345). Yetdocs/explanation/security-model.md:371-373already promises "OSC 8 hyperlinks display the target URL on hover or in a status surface; the visible link text is never trusted to represent the target." That is a documented commitment the code does not meet, so the preview is not new scope, it is a doc/code gap.LinkText::CAP(=felis_vt::OSC_BUFFER_LIMIT) atcrates/felis-client-core/src/shadow.rs:321-334vialink_entry(:495-503), so URI length at activation is already bounded; the daemon'sLINK_TABLE_BYTE_CAP(8 MiB,felis-grid/src/link_table.rs:59) bounds the table.docs/reference/spec.md:219REQ-910 says allowlist + explicit activation only;docs/reference/security-audits.md:70-86records the argv/ShellExecuteWshape. Neither says anything about activation-time re-validation, preview safety, or log contents.The issue's "even if stored by a future grid path" framing is right: today the grid is the sole gate, and the client trusts whatever
GridMsg::Hyperlinkcarries (a non-felis daemon, or a future grid change, bypasses it).Verdict
accept-with-changes. Principle check passes: the feature is the explicit OSC 8 activation the principles already name (principle 2 lists OSC 8; principle 4 is satisfied because nothing scans content). The changes are about where the type lives and how minimal the preview is:
ActivationTargetinfelis-client-core(newhyperlink.rs), notfelis-client: it needs no winit/wgpu, it is testable on every CI host, and any future frontend (felis-<frontend>exec target per CLAUDE.md) reuses it.felis-client::hyperlink::open_urlthen takes&ActivationTargetonly.hyperlink.rs:27-41), rendered through the existing overlay path rather than a new status bar. A persistent status surface would be layout felis does not own (principle 1).security-model.md:377-380records that adding a scheme is a design decision, not config. Keep that.Approach
felis-client-core (
src/hyperlink.rs, new; export fromlib.rs):Bidi rejection rather than stripping: a URI carrying U+202E is hostile by construction (RFC 3986 URIs are ASCII after IRI mapping), and stripping would activate a target the user never saw.
felis-client:
hyperlink.rs:open_url(target: &ActivationTarget). On Unix, factor the argv shape intofn launch_argv(target) -> (&'static str, &str)and pin it in a test the wayShellExecuteArgsis pinned (hyperlink.rs:127-152), so the Linux/macOS boundary is assertable on the CI that exists.ShellExecuteArgs::for_url(&ActivationTarget); the NUL check inwide_zstays as defense in depth.event_handler.rs:922-938:url_at→ActivationTarget::parse; onErrdo nothing butdebug!(?rejection, "OSC 8 activation refused")(rejection carries no URI). On success:info!(scheme = ?t.scheme(), len = t.as_str().len(), "opened OSC 8 hyperlink"); the failure arm logs?erronly. Addhover_target: Option<ActivationTarget>updated whereupdate_mouse_cursor_iconruns, and aLinkPreviewOverlay { text }passed to the renderer alongside the confirm overlay (event_handler.rs:1110-1117).felis-render-wgpu: reuse theConfirmOverlaytext drawing (app_methods.rs:592-596) for a bottom-anchored single-line preview; no new shader.Tests
parserejectsjavascript:alert(1),data:text/html,…,JAVASCRIPT:andvbscript:; acceptsHTTPS://andmailto:; rejects NUL,\x1b,\u{85},\u{202e},\u{2066};previewclips at the cap and never yields achar::is_controlor bidi char (proptest over arbitrary strings:parse(s).map(|t| t.preview(64))never contains a forbidden char).launch_argvtest (program name + single arg, verbatim); Windows tests retargeted toActivationTarget; the existingHOSTILE_URLSlist (hyperlink.rs:430-441) stays.tracingsubscriber capturing to aVec<u8>around the activation path asserts the captured text does not contain the URI (client-core hastracing; if a test subscriber dependency is unwanted, unit-testlog_fieldsand keep the call sites free of%uriby grep in review).Docs cascade (
doc-cascadeskill)docs/explanation/security-model.md"OSC 8 hyperlinks and OSC 7 CWD": add the activation-boundary bullet (typed target, re-check at activation, rejected classes incl. bidi), the preview bullet (replace the unmet promise at:371-373with what ships: Ctrl-hover preview), and a logging bullet (scheme class + length only). Record the rejected alternative (stripping bidi) inline.docs/reference/spec.mdREQ-910: append "re-validated at activation from a typed target; logs carry scheme class and length only; a control-safe preview is shown while the activation modifier is held."docs/reference/security-audits.md:70-86: update the OSC 8 entry (typed boundary, Unix argv pin).Ctrl+Click/Ctrl+Leftunderdocs/reference/) gains the preview sentence.CHANGELOG.mdUnreleased / Changed: preview on Ctrl-hover; activation refuses malformed targets; logs no longer contain URIs.skills/felis: no change (no CLI/IPC surface).Dependencies
None. #12 puts it first and that still holds; it touches none of the wire/limits work in #14–#16/#49.
Risk/effort
M. Most of the cost is the preview overlay in
felis-render-wgpu; the boundary type and log change are S. Main risk: Windows launcher path remains untestable in CI (existing gap,security-audits.md:82-86); mitigated by keeping the Windows change to the argument type only.Labels
Keep
priority/P0,release/v0.1.0. The log disclosure alone would be P1, but the activation boundary is the documented security model's stated guarantee and cheap to close before the first tag.