Skip to content

fix(cliprdr): validate locked file requests against the list their lock covers - #1981

Draft
Piero (pierophp) wants to merge 2 commits into
Devolutions:masterfrom
pierophp:feat/receiver-side-locked-file-list
Draft

Piero (pierophp) wants to merge 2 commits into
Devolutions:masterfrom
pierophp:feat/receiver-side-locked-file-list

Conversation

@pierophp

Copy link
Copy Markdown

Stacked on #1980. GitHub cannot base a cross-fork PR on another fork branch, so the diff here also carries #1980's commit. The change under review is the second commit, fix(cliprdr): validate locked file requests against the list their lock covers. Rebasing onto master once #1980 lands makes it standalone.

Problem

[MS-RDPECLIP] 3.1.5.4.5:

If the clipDataId field is present, then the locked File Stream data associated with the ID MUST be used to service the request.

The sender side already honours this. A Lock PDU received from the remote snapshots local_file_list into locked_file_lists, and an incoming FileContentsRequest carrying a clipDataId is served from that snapshot — with the spec text quoted inline at the use site.

The receiver side has no equivalent. A Lock PDU we send asks the remote to keep its File Stream data alive across a clipboard change. request_file_contents even documents the contract:

Caller can override by setting request.data_id explicitly before calling this method. This allows the receiver to correlate the request with locked File Stream data even if the clipboard has changed since the lock was sent.

…and twelve lines later validates the index against remote_file_list, which handle_format_list clears on every clipboard change and the next FormatDataResponse overwrites:

if let Some(ref file_list) = self.remote_file_list {
    if file_list.files.len() <= validated_file_index {
        return Err(/* "file index out of bounds for remote file list" */);

So: lock a two-file selection, let the remote replace it with a one-file one, and an already-issued request for index 1 fails — even though the lock is precisely the promise that the remote still holds that data. handle_format_list's own comment says this is not the intent:

// Transition active locks to Expired — but do NOT send Unlock PDUs yet.
// Active downloads from the previous clipboard may still be using these locks.

With a server embedder that surfaces the error, that failure reaches the session.

Change

Mirrors locked_file_lists for the receiving direction:

  • locked_remote_file_lists: HashMap<u32, PackedFileList> — the remote list snapshotted under whatever lock was active when it arrived, at the existing on_remote_file_list call site.
  • Bounds and range validation consult that snapshot when the request carries a data_id, falling back to remote_file_list otherwise.
  • The snapshot is released with its lock, in both the drive_timeouts sweep and release_outgoing_locks.
  • Bounded by the same MAX_LOCKED_FILE_LISTS as the sender side.

handle_format_list is untouched: it still clears remote_file_list, and the snapshots deliberately outlive it.

Tests

In crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs:

  • a_locked_request_validates_against_the_list_its_lock_covers — drives two selections through the real protocol flow (FormatListinitiate_pasteFormatDataResponse), then requests A's second file under A's clipDataId. Fails on master with "file index out of bounds for remote file list"; passes here. The same index without a lock is still rejected against the current selection.
  • a_snapshot_is_released_with_the_lock_it_belongs_to — after the sweep sends Unlock, the snapshot must stop validating requests, since the remote has released the data.

All 213 clipboard tests pass.

🤖 Generated with Claude Code

Piero (pierophp) and others added 2 commits September 18, 2026 14:56
Two halves of the same problem: a rejected clipboard operation was both
invisible to the backend that was waiting on it and fatal to the session.

ironrdp-cliprdr: every rejection path in `request_file_contents` now
reports `FileContentsResponse::new_error(stream_id)` to the backend before
returning, the same treatment `FormatListResponse::Fail` already gives
pending requests. The `Err` is unchanged for callers that surface it -- but
it carries no stream id, so on its own it leaves whatever awaits that
transfer hanging until an unrelated timeout fires.

ironrdp-server: `dispatch_server_events` propagated every `CliprdrServer`
error with `?`, which ends `client_loop` and tears the connection down. A
stale file contents request, a capability that was never negotiated or
backpressure on pending transfers would therefore disconnect a session
whose display, audio and input were healthy. The `ClipboardMessage::Error`
arm three lines above already logged and continued; the two paths now
agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ck covers

[MS-RDPECLIP] 3.1.5.4.5: "If the clipDataId field is present, then the
locked File Stream data associated with the ID MUST be used to service the
request."

The sender side already honours this: a Lock PDU received from the remote
snapshots `local_file_list` into `locked_file_lists`, and an incoming
FileContentsRequest carrying a clipDataId is served from that snapshot.

The receiver side had no equivalent. A Lock PDU *we* send asks the remote
to keep its File Stream data alive across a clipboard change, and
`request_file_contents` even documents that a caller may set `data_id`
explicitly so "the receiver can correlate the request with locked File
Stream data even if the clipboard has changed since the lock was sent" --
but the lindex bounds check ran against `remote_file_list`, which a new
Format List clears and the next FormatDataResponse overwrites.

The result: replacing a two-file selection with a one-file one made an
already-issued request for index 1 fail against the new list, even though
the lock meant the remote still held the data. With a server embedder that
surfaces the error, that failure reached the session.

Snapshot the remote file list under the active lock when it arrives, key
the bounds and range validation off that snapshot when the request carries
a clipDataId, and release it with the lock -- mirroring `locked_file_lists`
on the sender side, bounded by the same MAX_LOCKED_FILE_LISTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant