From 03932d573fa362a2fd4075659ea8e051e5788598 Mon Sep 17 00:00:00 2001 From: bponders12-cell Date: Tue, 1 Sep 2026 21:19:15 -0400 Subject: [PATCH] fix: correct Worker TypeScript token subject validation --- cloudflare/motorsignal-r2-worker/src/index.ts | 143 +++--------------- 1 file changed, 22 insertions(+), 121 deletions(-) diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index f5007d5..b1aee49 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -28,9 +28,7 @@ const jwks = createRemoteJWKSet(FIREBASE_JWKS); export default { async fetch(request: Request, env: Env): Promise { - if (request.method === 'OPTIONS') { - return new Response(null, { headers: CORS_HEADERS }); - } + if (request.method === 'OPTIONS') return new Response(null, { headers: CORS_HEADERS }); const url = new URL(request.url); const path = url.pathname; @@ -38,117 +36,55 @@ export default { try { if (path === '/' || path === '/health') { - return jsonResponse({ - status: 'ok', - service: 'motorsignal-r2-gateway', - project: projectId, - environment: env.ENVIRONMENT || 'production', - }); + return jsonResponse({ status: 'ok', service: 'motorsignal-r2-gateway', project: projectId, environment: env.ENVIRONMENT || 'production' }); } const authHeader = request.headers.get('Authorization'); - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401); - } + if (!authHeader || !authHeader.startsWith('Bearer ')) return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401); const idToken = authHeader.substring(7).trim(); const authUser = await verifyFirebaseToken(idToken, projectId); - if (!authUser?.sub) { - return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401); - } - + if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401); const authenticatedUid = authUser.sub; if (request.method === 'PUT' && path === '/r2/upload') { const remoteRideId = request.headers.get('X-Remote-Ride-Id'); - if (!remoteRideId || !isValidRideId(remoteRideId)) { - return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400); - } - + if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400); const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; const clientSha256 = request.headers.get('X-Bundle-Sha256'); const appVersion = request.headers.get('X-App-Version') || 'unknown'; - if (!request.body) return jsonResponse({ error: 'Missing binary payload body' }, 400); - const payloadBuffer = await request.arrayBuffer(); if (payloadBuffer.byteLength === 0) return jsonResponse({ error: 'Empty payload body' }, 400); - - const computedHashBuffer = await crypto.subtle.digest('SHA-256', payloadBuffer); - const computedSha256 = bufferToHex(computedHashBuffer); - + const computedSha256 = bufferToHex(await crypto.subtle.digest('SHA-256', payloadBuffer)); if (clientSha256 && clientSha256.trim() && clientSha256.trim().toLowerCase() !== computedSha256) { - return jsonResponse({ - error: 'Payload integrity check failed: SHA-256 mismatch', - expected: clientSha256, - computed: computedSha256, - }, 422); + return jsonResponse({ error: 'Payload integrity check failed: SHA-256 mismatch', expected: clientSha256, computed: computedSha256 }, 422); } - const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, { httpMetadata: { contentType: 'application/zip' }, - customMetadata: { - uid: authenticatedUid, - remoteRideId, - sha256: computedSha256, - appVersion, - uploadedAt: new Date().toISOString(), - }, - }); - - return jsonResponse({ - success: true, - objectKey, - size: r2Object.size, - etag: r2Object.etag, - sha256: computedSha256, - uploadedAt: new Date().toISOString(), + customMetadata: { uid: authenticatedUid, remoteRideId, sha256: computedSha256, appVersion, uploadedAt: new Date().toISOString() }, }); + return jsonResponse({ success: true, objectKey, size: r2Object.size, etag: r2Object.etag, sha256: computedSha256, uploadedAt: new Date().toISOString() }); } if (request.method === 'POST' && path === '/r2/upload-url') { const body = (await request.json().catch(() => ({}))) as Record; const remoteRideId = body.remoteRideId as string | undefined; - if (!remoteRideId || !isValidRideId(remoteRideId)) { - return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400); - } - + if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400); const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; - return jsonResponse({ - uploadEndpoint: `${url.origin}/r2/upload`, - objectKey, - method: 'PUT', - headers: { - 'Content-Type': 'application/zip', - 'X-Remote-Ride-Id': remoteRideId, - }, - }); + return jsonResponse({ uploadEndpoint: `${url.origin}/r2/upload`, objectKey, method: 'PUT', headers: { 'Content-Type': 'application/zip', 'X-Remote-Ride-Id': remoteRideId } }); } if (request.method === 'GET' && path === '/r2/download') { const remoteRideId = url.searchParams.get('rideId'); - if (!remoteRideId || !isValidRideId(remoteRideId)) { - return jsonResponse({ error: 'Invalid rideId parameter' }, 400); - } + if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); - // Defense in depth: Firebase token verification establishes identity, - // then Firestore ownership establishes that this specific ride belongs - // to that identity before R2 is touched. - const ride = await fetchOwnedRide( - projectId, - authenticatedUid, - remoteRideId, - idToken, - ); + const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403); if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404); if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409); const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; - if (ride.objectKey && ride.objectKey !== objectKey) { - return jsonResponse({ error: 'Telemetry object ownership metadata mismatch' }, 409); - } - const object = await env.R2_BUCKET.get(objectKey); if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404); @@ -160,13 +96,11 @@ export default { const sha256 = object.customMetadata?.sha256; if (sha256) headers.set('X-MotorSignal-Sha256', sha256); for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); - return new Response(object.body, { status: 200, headers }); } return jsonResponse({ error: 'Endpoint not found' }, 404); } catch (err: unknown) { - // Do not expose internal exception details to callers in production. console.error('MotorSignal Worker exception', err); return jsonResponse({ error: 'Internal Worker Exception' }, 500); } @@ -175,15 +109,13 @@ export default { async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise { try { - const expectedIssuer = `https://securetoken.google.com/${expectedProjectId}`; const { payload } = await jwtVerify(token, jwks, { - issuer: expectedIssuer, + issuer: `https://securetoken.google.com/${expectedProjectId}`, audience: expectedProjectId, algorithms: ['RS256'], clockTolerance: 60, }); - - if (typeof payload.sub !== 'string' || payload.sub.trim().isEmpty) return null; + if (typeof payload.sub !== 'string' || payload.sub.trim().length === 0) return null; if (typeof payload.exp !== 'number') return null; return payload as FirebaseTokenPayload; } catch { @@ -191,33 +123,13 @@ async function verifyFirebaseToken(token: string, expectedProjectId: string): Pr } } -interface OwnedRide { - objectKey?: string; -} - +interface OwnedRide { objectKey?: string; } type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid'; -/** - * Reads the authenticated user's ride document through Firestore's REST API. - * The already verified Firebase ID token is passed through, so Firestore - * Security Rules still apply to the request. - */ -async function fetchOwnedRide( - projectId: string, - uid: string, - remoteRideId: string, - idToken: string, -): Promise { +async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise { const documentPath = encodeFirestorePath(`users/${uid}/rides/${remoteRideId}`); const endpoint = `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/databases/(default)/documents/${documentPath}`; - - const response = await fetch(endpoint, { - method: 'GET', - headers: { - Authorization: `Bearer ${idToken}`, - Accept: 'application/json', - }, - }); + const response = await fetch(endpoint, { method: 'GET', headers: { Authorization: `Bearer ${idToken}`, Accept: 'application/json' } }); if (response.status === 401 || response.status === 403) return 'unauthorized'; if (response.status === 404) return 'not-found'; @@ -234,20 +146,12 @@ async function fetchOwnedRide( if (ownerId !== uid) return 'unauthorized'; if (storedId && storedId !== remoteRideId) return 'invalid'; - if (!objectKey) return 'invalid'; - if (objectKey !== `users/${uid}/rides/${remoteRideId}/telemetry.zip`) return 'invalid'; - + if (!objectKey || objectKey !== `users/${uid}/rides/${remoteRideId}/telemetry.zip`) return 'invalid'; return { objectKey }; } -interface FirestoreValue { - stringValue?: string; - mapValue?: { fields?: Record }; -} - -interface FirestoreDocument { - fields?: Record; -} +interface FirestoreValue { stringValue?: string; mapValue?: { fields?: Record }; } +interface FirestoreDocument { fields?: Record; } function firestoreString(value: FirestoreValue | undefined): string | undefined { return typeof value?.stringValue === 'string' ? value.stringValue : undefined; @@ -258,10 +162,7 @@ function encodeFirestorePath(path: string): string { } function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + return new Response(JSON.stringify(body), { status, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }); } function bufferToHex(buffer: ArrayBuffer): string {