Skip to content

Commit a225e9c

Browse files
committed
fix(security): address remaining review findings
Follow-up to #1. These were raised in the final CodeRabbit review, which I merged without reading — the automation I used watched inline-comment counts and missed that the findings were in the review body. - `validate_channel_against_youtube` took `&mut Channel` but dropped the validated result, so the client-supplied channel name was persisted instead of the canonical one from the feed. Upstream behaved the same way, but the refactor in #1 left the two paths inconsistent, since the video path does assign it back. Names are only fuzzily matched before being accepted, and the channel table is shared between accounts. - `validate_videos_against_youtube` zipped two slices whose equal length was documented but unenforced. `zip` truncates, so a short plan would silently skip RSS validation for the tail. No caller does that today, but it gates metadata validation and is the same failure mode as the dedup bypass fixed in #1. - `verify_image_url` checked the host but not the scheme, so `http://ytimg.com/…` was accepted and handed to clients to load. Now requires https. - Single `subscribe` only pre-checked the quota, outside any transaction, so concurrent requests could each see an under-quota count and together exceed it. Now matches the bulk path. - `add_to_playlist` checked ownership before the RSS round-trips and never again, so a playlist deleted in that window surfaced as an opaque database error rather than PlaylistNotExists. Re-checked inside the transaction. - `add_to_watch_history` answered `200 null` if the stored batch came back empty. - `exceeds_row_quota` no longer takes an `incoming` count: every caller passes 0 now that the quota is enforced on rows that actually exist.
1 parent 73effb2 commit a225e9c

6 files changed

Lines changed: 63 additions & 25 deletions

File tree

src/database/quota.rs

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,12 @@ pub async fn count_playback_speeds(
6262
.await
6363
}
6464

65-
/// Whether storing `incoming` further rows would exceed the per-table quota.
65+
/// Whether an account holds more rows than the per-table quota allows.
6666
///
67-
/// `incoming` is an upper bound: some of those rows may be updates to existing
68-
/// ones, so this can reject slightly early at the very top of the range.
69-
pub fn exceeds_row_quota(stored_rows: i64, incoming: usize) -> bool {
70-
let incoming = i64::try_from(incoming).unwrap_or(i64::MAX);
71-
72-
stored_rows.saturating_add(incoming) > MAX_ROWS_PER_ACCOUNT
67+
/// Callers check the rows that actually exist rather than predicting how many a
68+
/// batch will add, because every bulk write path is an upsert.
69+
pub fn exceeds_row_quota(stored_rows: i64) -> bool {
70+
stored_rows > MAX_ROWS_PER_ACCOUNT
7371
}
7472

7573
#[cfg(test)]
@@ -78,14 +76,10 @@ mod tests {
7876

7977
#[test]
8078
fn quota_allows_up_to_the_limit() {
81-
assert!(!exceeds_row_quota(0, 1));
82-
assert!(!exceeds_row_quota(MAX_ROWS_PER_ACCOUNT - 1, 1));
83-
assert!(exceeds_row_quota(MAX_ROWS_PER_ACCOUNT, 1));
84-
}
85-
86-
#[test]
87-
fn quota_does_not_overflow_on_absurd_batches() {
88-
assert!(exceeds_row_quota(i64::MAX, usize::MAX));
89-
assert!(exceeds_row_quota(0, usize::MAX));
79+
assert!(!exceeds_row_quota(0));
80+
assert!(!exceeds_row_quota(MAX_ROWS_PER_ACCOUNT - 1));
81+
assert!(!exceeds_row_quota(MAX_ROWS_PER_ACCOUNT));
82+
assert!(exceeds_row_quota(MAX_ROWS_PER_ACCOUNT + 1));
83+
assert!(exceeds_row_quota(i64::MAX));
9084
}
9185
}

src/handlers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ pub fn check_already_over_quota(stored_rows: i64) -> HandlerResult<()> {
142142
/// incoming entries were updates rather than inserts. Returning an error from
143143
/// inside the transaction rolls the writes back.
144144
pub fn check_stored_rows(stored_rows: i64) -> HandlerResult<()> {
145-
if crate::database::quota::exceeds_row_quota(stored_rows, 0) {
145+
if crate::database::quota::exceeds_row_quota(stored_rows) {
146146
return Err(HandlerError::StorageQuotaExceeded);
147147
}
148148

src/handlers/playlists.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ async fn add_to_playlist(
244244
conn.transaction::<_, HandlerError, _>(|conn| {
245245
let (groups, playlist_id, account_id) = (&groups, &*playlist_id, &account.id);
246246
async move {
247+
// Re-check ownership: the earlier check happened before the RSS
248+
// round-trips, which can take seconds, so the playlist may have been
249+
// deleted since. Without this the inserts fail as an opaque database
250+
// error instead of PlaylistNotExists.
251+
get_owned_playlist_or_error(conn, playlist_id, account_id).await?;
252+
247253
for (channel, videos) in groups {
248254
// store channel information first before storing video to ensure data integrity
249255
create_or_update_channel(conn, channel)

src/handlers/subscriptions.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,25 @@ async fn subscribe(
167167
}
168168

169169
let mut conn = get_db_conn!(pool);
170-
match add_subscription_by_account_id(&mut conn, &channel, &account.id).await {
171-
Ok(_) => Ok(HttpResponse::Ok()),
172-
Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext(
173-
err.to_string(),
174-
)),
175-
}
170+
// Mirrors the bulk path: without an authoritative post-write check inside a
171+
// transaction, concurrent single subscribes each see an under-quota count and
172+
// can together push the account past the limit.
173+
conn.transaction::<_, HandlerError, _>(|conn| {
174+
let (channel, account_id) = (&channel, &account.id);
175+
async move {
176+
add_subscription_by_account_id(conn, channel, account_id).await?;
177+
check_stored_rows(
178+
count_subscriptions(conn, account_id)
179+
.await
180+
.map_err(|_| HandlerError::InternalDatabaseError)?,
181+
)?;
182+
Ok(())
183+
}
184+
.scope_boxed()
185+
})
186+
.await?;
187+
188+
Ok(HttpResponse::Ok())
176189
}
177190

178191
#[utoipa::path(responses((status = OK)), security(("api_jwt_token" = [])))]

src/handlers/watch_history.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,11 @@ async fn add_to_watch_history(
273273
store_watch_history_items(&pool, &account.id, vec![watch_history_item.into_inner()])
274274
.await?;
275275

276-
Ok(HttpResponse::Ok().json(stored.pop()))
276+
// One item in, one item out. Answering `200 null` if that ever stops holding
277+
// would be a silently wrong response, so make it an error instead.
278+
let stored = stored.pop().ok_or(HandlerError::InternalDatabaseError)?;
279+
280+
Ok(HttpResponse::Ok().json(stored))
277281
}
278282

279283
#[utoipa::path(responses((status = OK)), security(("api_jwt_token" = [])))]

src/validation.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ fn verify_image_url(image_url: &str) -> bool {
3131
return false;
3232
};
3333

34+
// Clients load these URLs, so do not store plaintext or exotic schemes.
35+
if url.scheme() != "https" {
36+
return false;
37+
}
38+
3439
let Some(host) = url.host_str() else {
3540
return false;
3641
};
@@ -143,7 +148,11 @@ pub async fn validate_channel_against_youtube(channel: &mut Channel) -> HandlerR
143148
.await
144149
.map_err(|_| HandlerError::YouTubeConnectError)?;
145150

146-
validate_channel_information(channel.clone(), &rss_channel)
151+
// Assign the result back: `validate_channel_information` replaces the name
152+
// with the canonical one from the feed, and dropping it here would persist
153+
// the client-supplied name instead. `validate_videos_against_youtube` does
154+
// the same, so both paths normalize identically.
155+
(*channel) = validate_channel_information(channel.clone(), &rss_channel)
147156
.map_err(|_| HandlerError::ValidationError)?;
148157

149158
Ok(())
@@ -209,6 +218,15 @@ pub async fn validate_videos_against_youtube(
209218
return Ok(());
210219
}
211220

221+
// `zip` below truncates to the shorter slice, which would silently skip
222+
// validation for the tail. This gates metadata validation, so enforce the
223+
// contract rather than documenting it.
224+
if needs_validation.len() != video_datas.len() {
225+
return Err(HandlerError::ValidationErrorWithContext(
226+
"validation plan does not match the batch".to_owned(),
227+
));
228+
}
229+
212230
for video in video_datas.iter() {
213231
if video.uploader != *channel {
214232
return Err(HandlerError::ValidationErrorWithContext(
@@ -390,6 +408,9 @@ mod test {
390408
assert!(!verify_image_url("https://ytimg.com.evil.net/a.jpg"));
391409
// real subdomains are still accepted
392410
assert!(verify_image_url("https://yt3.googleusercontent.com/a.jpg"));
411+
// only https, since clients load whatever is stored here
412+
assert!(!verify_image_url("http://i1.ytimg.com/vi/x/hqdefault.jpg"));
413+
assert!(!verify_image_url("ftp://i1.ytimg.com/vi/x/hqdefault.jpg"));
393414
}
394415

395416
#[actix_rt::test]

0 commit comments

Comments
 (0)