qconnect: Rust backstop validates per-track origins server-side

v2_qconnect_send_command_with_admission trusted a single frontend origin and
never inspected track_ids. Add track_origins[] to the request and
validate_track_origins_for_admission: re-evaluate every track via the core
gate, block if any is non-Qobuz or the list is empty. The per-track check is
only enforced when the caller actually ships per-track origins (bulk queue
loads); single-track play-next/play-later carry no track_origins and stay
gated by the command-level origin alone, so an empty list never over-blocks
them. The existing command-level check and admission_blocked emit are kept.

Frontend loadQconnectQueue now computes per-track origins from its tracks arg
via resolveQconnectTrackOrigin and ships them to the sender.

Defers the optional DB cross-check (LibraryState / has_local_track_by_qobuz_id
defense-in-depth) as a follow-up; this commit is the per-track-origins
validator + wiring + test only.

cc
This commit is contained in:
vicrodh committed 2026-06-01 02:34:42 -06:00
1 parent 82effd68ad
commit 653ded1e72
4 files changed
+67 -5

No files matched your search

+39 -3
View File
@@ -5,7 +5,7 @@
//! to `QconnectServiceState` after a `runtime.check_requirements` gate.
use qconnect_app::{
evaluate_remote_queue_admission, resolve_handoff_intent, QConnectQueueState,
evaluate_remote_queue_admission, resolve_handoff_intent, AdmissionDecision, QConnectQueueState,
QConnectRendererState, QconnectStartupMode, QueueCommandType, RendererReport,
RendererReportType,
};
@@ -182,6 +182,26 @@ pub async fn v2_qconnect_evaluate_queue_admission(
})
}
/// Server-side backstop: re-evaluate EVERY track's origin, independent of the
/// command-level `origin`. A bare `track_id` (a `u64`) cannot prove "Qobuz vs
/// local/Plex" on its own, so the frontend ships per-track origins and the gate
/// re-validates each one here. An empty list is blocked: we cannot prove the
/// queue is all-Qobuz, so we refuse rather than trust the command-level origin.
pub(super) fn validate_track_origins_for_admission(
origins: &[QconnectTrackOrigin],
) -> AdmissionDecision {
if origins.is_empty() {
return AdmissionDecision::block("empty_track_origins_blocked");
}
for origin in origins {
let decision = evaluate_remote_queue_admission(origin.into_core_origin());
if !decision.accepted {
return decision;
}
}
AdmissionDecision::allow("all_track_origins_qobuz")
}
#[tauri::command]
pub async fn v2_qconnect_send_command_with_admission(
request: QconnectSendCommandWithAdmissionRequest,
@@ -196,8 +216,24 @@ pub async fn v2_qconnect_send_command_with_admission(
if request.command_type.requires_remote_queue_admission() {
let core_origin = request.origin.into_core_origin();
let decision = evaluate_remote_queue_admission(core_origin);
if !decision.accepted {
let command_decision = evaluate_remote_queue_admission(core_origin);
// Per-track backstop: only enforced when the caller actually ships
// per-track origins (bulk queue loads). Single-track play-next/play-later
// legitimately carry no `track_origins` and gate on the command-level
// origin alone, so an empty list here must NOT over-block them.
let per_track_decision = if request.track_origins.is_empty() {
command_decision.clone()
} else {
validate_track_origins_for_admission(&request.track_origins)
};
if !command_decision.accepted || !per_track_decision.accepted {
let decision = if !per_track_decision.accepted {
per_track_decision
} else {
command_decision
};
log::warn!(
"[QConnect] send_command_with_admission: BLOCKED reason={}",
decision.reason
+11
View File
@@ -98,6 +98,17 @@ fn maps_qconnect_track_origin_to_core_origin_and_handoff() {
);
}
#[test]
fn blocks_command_when_any_track_origin_is_non_qobuz() {
use super::commands::validate_track_origins_for_admission;
use super::QconnectTrackOrigin::*;
assert!(validate_track_origins_for_admission(&[QobuzOnline, QobuzOfflineCache]).accepted);
assert!(!validate_track_origins_for_admission(&[QobuzOnline, LocalLibrary]).accepted);
assert!(!validate_track_origins_for_admission(&[Plex]).accepted);
assert!(!validate_track_origins_for_admission(&[ExternalUnknown]).accepted);
assert!(!validate_track_origins_for_admission(&[]).accepted); // empty -> blocked
}
#[test]
fn refreshes_local_renderer_id_from_exact_device_uuid_match() {
let local_device_uuid = super::transport::resolve_qconnect_device_uuid();
+6
View File
@@ -182,6 +182,12 @@ pub struct QconnectAdmissionResult {
pub struct QconnectSendCommandWithAdmissionRequest {
pub command_type: QconnectOutboundCommandType,
pub origin: QconnectTrackOrigin,
/// Per-track origins for the IDs in `payload.track_ids`. The server gate
/// re-validates EACH, independent of the command-level `origin`. Defaults
/// to empty for callers that legitimately carry no track payload (e.g.
/// single-track play-next/play-later that gate on the command-level origin).
#[serde(default)]
pub track_origins: Vec<QconnectTrackOrigin>,
#[serde(default)]
pub payload: Value,
}
+11 -2
View File
@@ -202,12 +202,14 @@ async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<Qconne
async function sendQconnectQueueCommandWithAdmission(
commandType: QconnectQueueCommandType,
origin: QconnectTrackOrigin,
payload: Record<string, unknown>
payload: Record<string, unknown>,
trackOrigins: QconnectTrackOrigin[] = []
): Promise<void> {
await invoke('v2_qconnect_send_command_with_admission', {
request: {
command_type: commandType,
origin,
track_origins: trackOrigins,
payload
}
});
@@ -610,8 +612,15 @@ export async function loadQconnectQueue(
preview_track_ids: trackIds.slice(0, 8),
payload
});
// Per-track origins for the Rust backstop (fix #3): when backend-shaped
// tracks are supplied, ship each track's resolved origin so the server gate
// can re-validate every id, not just the command-level origin. Bare id-only
// callers send none and stay gated by the command-level origin alone.
const trackOrigins = tracks
? tracks.map((tr) => resolveQconnectTrackOrigin(tr, tr.is_local ?? false))
: [];
console.log('[QConnect/LoadQueue] sending queue_load_tracks');
await sendQconnectQueueCommandWithAdmission('queue_load_tracks', origin, payload);
await sendQconnectQueueCommandWithAdmission('queue_load_tracks', origin, payload, trackOrigins);
console.log('[QConnect/LoadQueue] SUCCESS');
// Sync local autoplay preference to QConnect server after queue load