Skip to main content

max / makenotwork

Run the paid-only blob gate on the multipart parts route blob_multipart_parts minted presigned part URLs without the gate that blob_upload_url and blob_multipart_start both run, so a first-party client that called it directly got upload credentials with no subscription. Call blob_upload_gate before signing anything, and short-circuit an existing content address with an empty part list, matching start.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 21:21 UTC
Signed with PGP, not checked
Commit: 2c368f903e2f55ae912febb1ab97848c02c9ccd0
Parent: 14dfdc9
2 files changed, +58 insertions, -1 deletion
@@ -533,3 +533,42 @@
533 533 .unwrap();
534 534 assert_eq!(pending, 0, "abort must clear the tracking row it opened");
535 535 }
536 +
537 + #[tokio::test]
538 + async fn multipart_parts_refused_without_subscription() {
539 + // The parts route mints upload credentials of its own, so it runs the same
540 + // paid-only gate `start` does. It did not, and an unsubscribed first-party
541 + // client that called it directly was handed presigned part URLs.
542 + let (mut h, _blobs) = harness_with_blobs().await;
543 + let user_id = h
544 + .signup("mp_partnosub", "mp_partnosub@example.com", "Password1!")
545 + .await;
546 + let (app_id, _key) = create_internal_app(&h.db, user_id).await;
547 + auth_as(&mut h, user_id, app_id, "user-key");
548 +
549 + let resp = h
550 + .client
551 + .post_json(
552 + "/api/sync/blobs/multipart/parts",
553 + &json!({
554 + "hash": fake_hash(0x21),
555 + "upload_id": "any-upload-id",
556 + "size_bytes": LARGE_BLOB,
557 + "first_part": 1,
558 + "count": 4,
559 + })
560 + .to_string(),
561 + )
562 + .await;
563 + assert_eq!(
564 + resp.status, 402,
565 + "parts must be refused without a sub: {}",
566 + resp.text
567 + );
568 + assert_eq!(resp.json::<Value>()["reason"], "no_subscription");
569 + assert!(
570 + !resp.text.contains("http"),
571 + "a refused parts request must mint no URL: {}",
572 + resp.text
573 + );
574 + }
@@ -249,6 +249,7 @@
249 249 )]
250 250 #[tracing::instrument(skip_all, name = "synckit::blob_multipart_parts")]
251 251 pub(super) async fn blob_multipart_parts(
252 + State(db): State<PgPool>,
252 253 State(storage): State<crate::AppStorage>,
253 254 sync_user: SyncUser,
254 255 Json(req): Json<BlobMultipartPartsRequest>,
@@ -257,13 +258,30 @@
257 258 AppError::ServiceUnavailable("SyncKit blob storage is not configured".to_string())
258 259 })?;
259 260
260 - validation::validate_sync_blob_hash(&req.hash)?;
261 261 if req.size_bytes <= 0 || req.size_bytes > constants::SYNCKIT_MAX_MULTIPART_BLOB_SIZE_BYTES {
262 262 return Err(AppError::BadRequest(
263 263 "Blob size is out of range".to_string(),
264 264 ));
265 265 }
266 266
267 + // The same gate `start` runs, and it belongs here too: a client that skips
268 + // `start` reaches this route directly, and without the gate an unsubscribed
269 + // first-party user was handed presigned part URLs. The gate also validates
270 + // the hash, which is why the standalone check above it is gone.
271 + match blob_upload_gate(&db, &sync_user, &req.hash).await? {
272 + Some(BlobUploadStop::NoSubscription) => return Ok(no_subscription_response()),
273 + // Mirrors `start`: the content address is already stored for this
274 + // user and app, so there is nothing to upload and no URL to mint.
275 + Some(BlobUploadStop::AlreadyExists) => {
276 + return Ok(Json(BlobMultipartPartsResponse {
277 + parts: Vec::new(),
278 + expires_in: 0,
279 + })
280 + .into_response());
281 + }
282 + None => {}
283 + }
284 +
267 285 let plan =
268 286 s3_storage::MultipartPlan::auto(req.size_bytes as u64).map_err(AppError::BadRequest)?;
269 287