Delegate bundle hash validation to authenticated R2 worker
This commit is contained in:
parent
4b8aa36bba
commit
663da1e409
|
|
@ -20,6 +20,7 @@ class R2UploadResult {
|
||||||
const R2UploadResult.permanentFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false);
|
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 {
|
class R2UploadService {
|
||||||
static const String defaultWorkerUrl = String.fromEnvironment('R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev');
|
static const String defaultWorkerUrl = String.fromEnvironment('R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev');
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
|
@ -30,15 +31,17 @@ class R2UploadService {
|
||||||
Future<R2UploadResult> uploadTelemetryBundle({required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45)}) async {
|
Future<R2UploadResult> 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 (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.');
|
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'^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 {
|
try {
|
||||||
final response = await _client.put(Uri.parse('$_workerBaseUrl/r2/upload'), headers: {
|
final response = await _client.put(Uri.parse('$_workerBaseUrl/r2/upload'), headers: {
|
||||||
'Authorization': 'Bearer ${idToken.trim()}',
|
'Authorization': 'Bearer ${idToken.trim()}',
|
||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
'X-Remote-Ride-Id': remoteRideId,
|
'X-Remote-Ride-Id': remoteRideId,
|
||||||
'X-Bundle-Sha256': sha256Hash.toLowerCase(),
|
'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(),
|
||||||
'X-App-Version': appVersion,
|
'X-App-Version': appVersion,
|
||||||
}, body: zipBytes).timeout(timeout);
|
}, body: zipBytes).timeout(timeout);
|
||||||
final status = response.statusCode;
|
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.');
|
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 size = data['size'];
|
||||||
final bytes = size is num && size >= 0 ? size.toInt() : zipBytes.length;
|
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);
|
return R2UploadResult.success(objectKey: objectKey, bytesWritten: bytes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return R2UploadResult.temporaryFailure('R2 upload response could not be verified: $e', statusCode: status);
|
return R2UploadResult.temporaryFailure('R2 upload response could not be verified: $e', statusCode: status);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue