diff --git a/lib/services/sync/r2_upload_service.dart b/lib/services/sync/r2_upload_service.dart index 4203897..1d0486b 100644 --- a/lib/services/sync/r2_upload_service.dart +++ b/lib/services/sync/r2_upload_service.dart @@ -20,6 +20,7 @@ class R2UploadResult { const R2UploadResult.permanentFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false); } +/// Uploads immutable ride telemetry bundles through the authenticated Worker gateway. class R2UploadService { static const String defaultWorkerUrl = String.fromEnvironment('R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev'); final http.Client _client; @@ -30,15 +31,17 @@ class R2UploadService { Future uploadTelemetryBundle({required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45)}) async { if (idToken.trim().isEmpty) return const R2UploadResult.authRequired('Firebase ID token is required for R2 upload.'); if (zipBytes.isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle is empty.'); + // Prevent path traversal at the client boundary. The Worker remains the + // authoritative verifier of SHA-256 and Firebase UID ownership. if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) return const R2UploadResult.permanentFailure('Invalid remote ride identifier.'); - if (!RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(sha256Hash)) return const R2UploadResult.permanentFailure('Invalid telemetry bundle SHA-256.'); + if (sha256Hash.trim().isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle SHA-256 is required.'); try { final response = await _client.put(Uri.parse('$_workerBaseUrl/r2/upload'), headers: { 'Authorization': 'Bearer ${idToken.trim()}', 'Content-Type': 'application/zip', 'X-Remote-Ride-Id': remoteRideId, - 'X-Bundle-Sha256': sha256Hash.toLowerCase(), + 'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(), 'X-App-Version': appVersion, }, body: zipBytes).timeout(timeout); final status = response.statusCode; @@ -51,8 +54,6 @@ class R2UploadService { if (objectKey is! String || !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || !objectKey.startsWith('users/')) return const R2UploadResult.permanentFailure('R2 worker returned an invalid object key.'); final size = data['size']; final bytes = size is num && size >= 0 ? size.toInt() : zipBytes.length; - // The worker may report an encoded/storage size independently; the upload - // is already cryptographically identified by X-Bundle-Sha256. return R2UploadResult.success(objectKey: objectKey, bytesWritten: bytes); } catch (e) { return R2UploadResult.temporaryFailure('R2 upload response could not be verified: $e', statusCode: status);