An authenticated legacy-sync account could take ownership of another account's subscription group, read its channel membership, or empty the group without owning it.
Severity: high. The attacker needs a valid account and a victim group ID. Group IDs are UUIDs; the audit did not identify an unauthenticated endpoint that lists them. Fully migrated encrypted profiles are stored separately.
Root cause and impact
The PATCH handler replaced the submitted account_id with the caller's account, then executed an UPDATE filtered only by group ID. Because the changeset included account_id, the mutation transferred the victim's group to the attacker.
Separately, DELETE removed group memberships by group ID before applying ownership filtering to the parent group deletion. An unauthorized delete therefore emptied the victim's group while leaving its parent record intact.
Vulnerable mutations:
|
pub async fn update_existing_subscription_group( |
|
conn: &mut DbConnection, |
|
subscription_group_: SubscriptionGroup, |
|
) -> Result<SubscriptionGroup, DbError> { |
|
diesel::update(subscription_group) |
|
.filter(id.eq(subscription_group_.id.clone())) |
|
.set(subscription_group_) |
|
.returning(SubscriptionGroup::as_returning()) |
|
.get_result(conn) |
|
.await |
|
} |
|
|
|
pub async fn delete_subscription_group_by_id( |
|
conn: &mut DbConnection, |
|
subscription_group_id_: &str, |
|
account_id_: &str, |
|
) -> Result<(), DbError> { |
|
// delete all linked channels first to ensure database integrity |
|
// TODO: use ON DELETE CASCADE |
|
diesel::delete(subscription_group_member) |
|
.filter(subscription_group_id.eq(subscription_group_id_)) |
|
.execute(conn) |
|
.await?; |
|
|
|
diesel::delete(subscription_group) |
|
.filter( |
|
id.eq(subscription_group_id_) |
|
.and(account_id.eq(account_id_)), |
|
) |
|
.execute(conn) |
|
.await?; |
|
|
|
Ok(()) |
Handler:
|
} |
|
|
|
#[utoipa::path(responses((status = OK, body = SubscriptionGroup)), security(("api_jwt_token" = [])))] |
|
#[patch("/{subscription_group_id}")] |
|
async fn update_subscription_group( |
|
account: Account, |
|
pool: WebData, |
|
subscription_group_id: web::Path<String>, |
|
subscription_group: web::Json<SubscriptionGroup>, |
|
) -> HandlerResult<impl Responder> { |
|
let mut conn = get_db_conn!(pool); |
|
|
|
let mut subscription_group = subscription_group.into_inner(); |
|
subscription_group.id = subscription_group_id.into_inner(); |
|
subscription_group.account_id = account.id; |
|
|
|
match update_existing_subscription_group(&mut conn, subscription_group).await { |
|
Ok(group) => Ok(HttpResponse::Ok().json(group)), |
|
Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext( |
|
err.to_string(), |
|
)), |
|
} |
|
} |
|
|
|
#[utoipa::path(responses((status = OK)), security(("api_jwt_token" = [])))] |
|
#[delete("/{subscription_group_id}")] |
|
async fn delete_subscription_group( |
|
account: Account, |
|
pool: WebData, |
|
subscription_group_id: web::Path<String>, |
|
) -> HandlerResult<impl Responder> { |
|
let mut conn = get_db_conn!(pool); |
|
|
|
match delete_subscription_group_by_id(&mut conn, &subscription_group_id, &account.id).await { |
|
Ok(_) => Ok(HttpResponse::Ok()), |
|
Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext( |
|
err.to_string(), |
|
)), |
|
} |
|
} |
|
|
|
async fn verify_is_subscription_group_owner( |
|
conn: &mut DbConnection, |
Reproduction with disposable accounts
- Register test accounts A and B. With B's token, subscribe to a test channel, create a subscription group, and add the channel to it.
- Retain the group ID as a test fixture.
- With A's token, PATCH
/v1/subscriptions/groups/{id} using {"id":"{id}","title":"Changed"}.
- Before the fix, A can list the stolen group and its channels, while it disappears from B's group list.
- In a fresh fixture, use A's token to DELETE B's group. Before the fix, B still has the group but its channel membership is empty.
Both variants were reproduced against isolated SQLite databases using the real migrations and database functions. No production group was targeted.
Fix and compatibility
Fixed by #8, merge commit b0c503efe7f71a16a9d88f4c1ba52027786e71c8.
Updates now filter by authenticated owner and modify only the group title. Deletes apply the ownership filter to the parent and use the existing foreign-key cascade for its memberships. Updating a missing or unowned group returns the existing 404 group error. Idempotent DELETE responses remain unchanged, but unauthorized requests cannot alter members.
Older clients keep their existing endpoints and request bodies. Legitimate renames and deletions continue to work. No schema migration, protocol bump, or client update is needed for these server fixes.
Validation and deployment
Regression tests cover unauthorized update/delete attempts, unchanged ownership and memberships, and legitimate owner renaming/deletion. The full offline SQLite suite passed 52 tests, PostgreSQL compilation passed, and both database variants built for AMD64 and ARM64.
Deployed to the operator's production instance on 2026-09-06 at 06:52 UTC, after the merged-commit build passed. The deployed SQLite image is ghcr.io/opentubex/sync-server@sha256:af8cb65e28dfccc021b656ed5a067ba87d1bd957a20939fe401748469ad6b7df.
Before deployment, HTTP checks against that image with a disposable database passed for playlist isolation, unauthorized group mutations, legitimate owner operations, legacy registration/subscription payloads, and encrypted collection round trips. A consistent production SQLite backup passed PRAGMA quick_check. After deployment, the production health endpoint returned HTTP 200 with the existing capabilities, and the running image matched the tested image.
The audit confirmed the code defects but did not establish exploitation of production accounts. These changes prevent future unauthorized mutations; recovery of any historical changes would need a separate investigation.
An authenticated legacy-sync account could take ownership of another account's subscription group, read its channel membership, or empty the group without owning it.
Severity: high. The attacker needs a valid account and a victim group ID. Group IDs are UUIDs; the audit did not identify an unauthenticated endpoint that lists them. Fully migrated encrypted profiles are stored separately.
Root cause and impact
The PATCH handler replaced the submitted
account_idwith the caller's account, then executed an UPDATE filtered only by group ID. Because the changeset includedaccount_id, the mutation transferred the victim's group to the attacker.Separately, DELETE removed group memberships by group ID before applying ownership filtering to the parent group deletion. An unauthorized delete therefore emptied the victim's group while leaving its parent record intact.
Vulnerable mutations:
sync-server/src/database/subscription_groups.rs
Lines 84 to 116 in cfcea1c
Handler:
sync-server/src/handlers/subscriptions.rs
Lines 280 to 322 in cfcea1c
Reproduction with disposable accounts
/v1/subscriptions/groups/{id}using{"id":"{id}","title":"Changed"}.Both variants were reproduced against isolated SQLite databases using the real migrations and database functions. No production group was targeted.
Fix and compatibility
Fixed by #8, merge commit
b0c503efe7f71a16a9d88f4c1ba52027786e71c8.Updates now filter by authenticated owner and modify only the group title. Deletes apply the ownership filter to the parent and use the existing foreign-key cascade for its memberships. Updating a missing or unowned group returns the existing 404 group error. Idempotent DELETE responses remain unchanged, but unauthorized requests cannot alter members.
Older clients keep their existing endpoints and request bodies. Legitimate renames and deletions continue to work. No schema migration, protocol bump, or client update is needed for these server fixes.
Validation and deployment
Regression tests cover unauthorized update/delete attempts, unchanged ownership and memberships, and legitimate owner renaming/deletion. The full offline SQLite suite passed 52 tests, PostgreSQL compilation passed, and both database variants built for AMD64 and ARM64.
Deployed to the operator's production instance on 2026-09-06 at 06:52 UTC, after the merged-commit build passed. The deployed SQLite image is
ghcr.io/opentubex/sync-server@sha256:af8cb65e28dfccc021b656ed5a067ba87d1bd957a20939fe401748469ad6b7df.Before deployment, HTTP checks against that image with a disposable database passed for playlist isolation, unauthorized group mutations, legitimate owner operations, legacy registration/subscription payloads, and encrypted collection round trips. A consistent production SQLite backup passed
PRAGMA quick_check. After deployment, the production health endpoint returned HTTP 200 with the existing capabilities, and the running image matched the tested image.The audit confirmed the code defects but did not establish exploitation of production accounts. These changes prevent future unauthorized mutations; recovery of any historical changes would need a separate investigation.