diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index 1905d65..f5007d5 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -1,8 +1,9 @@ +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; + /** - * MotorSignal — Cloudflare R2 Worker Gateway (Phase 2E Hardened) + * MotorSignal — Cloudflare R2 Worker Gateway * Secure authorization gateway for MotorSignal telemetry bundles on Cloudflare R2. - * Cryptographically validates Firebase Authentication ID tokens server-side, - * enforces SHA-256 payload integrity, and derives user-isolated object keys. + * Firebase ID tokens are cryptographically verified before any R2 access. */ export interface Env { @@ -11,16 +12,9 @@ export interface Env { ENVIRONMENT: string; } -interface FirebaseTokenPayload { - iss: string; - aud: string; - auth_time: number; - user_id: string; - sub: string; - iat: number; - exp: number; +interface FirebaseTokenPayload extends JWTPayload { + user_id?: string; email?: string; - [key: string]: unknown; } const CORS_HEADERS = { @@ -29,9 +23,11 @@ const CORS_HEADERS = { 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version', }; +const FIREBASE_JWKS = new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com'); +const jwks = createRemoteJWKSet(FIREBASE_JWKS); + export default { async fetch(request: Request, env: Env): Promise { - // 1. Handle CORS Preflight if (request.method === 'OPTIONS') { return new Response(null, { headers: CORS_HEADERS }); } @@ -41,252 +37,237 @@ export default { const projectId = env.FIREBASE_PROJECT_ID || 'motoapp-d22e0'; try { - // 2. Health check endpoint if (path === '/' || path === '/health') { - return new Response( - JSON.stringify({ - status: 'ok', - service: 'motorsignal-r2-gateway', - project: projectId, - environment: env.ENVIRONMENT || 'production', - }), - { headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } } - ); + return jsonResponse({ + status: 'ok', + service: 'motorsignal-r2-gateway', + project: projectId, + environment: env.ENVIRONMENT || 'production', + }); } - // 3. Authenticate Firebase ID Token const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { - return new Response(JSON.stringify({ error: 'Missing or malformed Authorization header' }), { - status: 401, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401); } const idToken = authHeader.substring(7).trim(); const authUser = await verifyFirebaseToken(idToken, projectId); - if (!authUser || !authUser.sub) { - return new Response(JSON.stringify({ error: 'Invalid, expired, or unauthorized Firebase ID token' }), { - status: 401, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + if (!authUser?.sub) { + return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401); } const authenticatedUid = authUser.sub; - // 4. Route: Direct PUT Upload (/r2/upload) if (request.method === 'PUT' && path === '/r2/upload') { const remoteRideId = request.headers.get('X-Remote-Ride-Id'); if (!remoteRideId || !isValidRideId(remoteRideId)) { - return new Response(JSON.stringify({ error: 'Invalid or missing X-Remote-Ride-Id header' }), { - status: 400, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400); } - // Strictly derive object path server-side to prevent cross-user pollution 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 new Response(JSON.stringify({ error: 'Missing binary payload body' }), { - status: 400, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); - } + if (!request.body) return jsonResponse({ error: 'Missing binary payload body' }, 400); - // Read payload bytes for SHA-256 verification and streaming const payloadBuffer = await request.arrayBuffer(); - if (payloadBuffer.byteLength === 0) { - return new Response(JSON.stringify({ error: 'Empty payload body' }), { - status: 400, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); - } + if (payloadBuffer.byteLength === 0) return jsonResponse({ error: 'Empty payload body' }, 400); - // Compute and verify SHA-256 checksum const computedHashBuffer = await crypto.subtle.digest('SHA-256', payloadBuffer); const computedSha256 = bufferToHex(computedHashBuffer); - if (clientSha256 && clientSha256.trim().isNotEmpty && clientSha256 !== computedSha256) { - return new Response( - JSON.stringify({ - error: 'Payload integrity check failed: SHA-256 mismatch', - expected: clientSha256, - computed: computedSha256, - }), - { status: 422, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } } - ); + if (clientSha256 && clientSha256.trim() && clientSha256.trim().toLowerCase() !== computedSha256) { + return jsonResponse({ + error: 'Payload integrity check failed: SHA-256 mismatch', + expected: clientSha256, + computed: computedSha256, + }, 422); } - // Store directly into Cloudflare R2 const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, { - httpMetadata: { - contentType: 'application/zip', - }, + httpMetadata: { contentType: 'application/zip' }, customMetadata: { uid: authenticatedUid, - remoteRideId: remoteRideId, + remoteRideId, sha256: computedSha256, - appVersion: appVersion, + appVersion, uploadedAt: new Date().toISOString(), }, }); - return new Response( - JSON.stringify({ - success: true, - objectKey: objectKey, - size: r2Object.size, - etag: r2Object.etag, - sha256: computedSha256, - uploadedAt: new Date().toISOString(), - }), - { status: 200, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } } - ); + return jsonResponse({ + success: true, + objectKey, + size: r2Object.size, + etag: r2Object.etag, + sha256: computedSha256, + uploadedAt: new Date().toISOString(), + }); } - // 5. Route: Presigned / Direct Upload Request Metadata (/r2/upload-url) 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 new Response(JSON.stringify({ error: 'Invalid remoteRideId in request payload' }), { - status: 400, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400); } const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; - - return new Response( - JSON.stringify({ - uploadEndpoint: `${url.origin}/r2/upload`, - objectKey: objectKey, - method: 'PUT', - headers: { - 'Content-Type': 'application/zip', - 'X-Remote-Ride-Id': remoteRideId, - }, - }), - { status: 200, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } } - ); + return jsonResponse({ + uploadEndpoint: `${url.origin}/r2/upload`, + objectKey, + method: 'PUT', + headers: { + 'Content-Type': 'application/zip', + 'X-Remote-Ride-Id': remoteRideId, + }, + }); } - // 6. Route: Download Telemetry Bundle (/r2/download) if (request.method === 'GET' && path === '/r2/download') { const remoteRideId = url.searchParams.get('rideId'); if (!remoteRideId || !isValidRideId(remoteRideId)) { - return new Response(JSON.stringify({ error: 'Invalid rideId parameter' }), { - status: 400, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + 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, + ); + 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`; - const object = await env.R2_BUCKET.get(objectKey); - - if (!object) { - return new Response(JSON.stringify({ error: 'Telemetry bundle not found in R2' }), { - status: 404, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + 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); + const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`); - for (const [k, v] of Object.entries(CORS_HEADERS)) { - headers.set(k, v); - } + headers.set('X-MotorSignal-Ride-Id', remoteRideId); + 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, { headers }); + return new Response(object.body, { status: 200, headers }); } - // Route Not Found - return new Response(JSON.stringify({ error: 'Endpoint not found' }), { - status: 404, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'Endpoint not found' }, 404); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - return new Response(JSON.stringify({ error: 'Internal Worker Exception', details: msg }), { - status: 500, - headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, - }); + // Do not expose internal exception details to callers in production. + console.error('MotorSignal Worker exception', err); + return jsonResponse({ error: 'Internal Worker Exception' }, 500); } }, }; -/** - * Validates the cryptographic structure and claims of a Firebase ID token. - */ async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise { try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - - // Decode JWT header & payload - const payloadJson = decodeBase64Url(parts[1]); - const payload = JSON.parse(payloadJson) as FirebaseTokenPayload; - - const now = Math.floor(Date.now() / 1000); - - // 1. Verify expiration (exp) with 60s tolerance - if (!payload.exp || payload.exp < now - 60) { - return null; - } - - // 2. Verify audience (aud) matches Firebase project ID - if (payload.aud !== expectedProjectId) { - return null; - } - - // 3. Verify issuer (iss) matches https://securetoken.google.com/ const expectedIssuer = `https://securetoken.google.com/${expectedProjectId}`; - if (payload.iss !== expectedIssuer) { - return null; - } + const { payload } = await jwtVerify(token, jwks, { + issuer: expectedIssuer, + audience: expectedProjectId, + algorithms: ['RS256'], + clockTolerance: 60, + }); - // 4. Verify subject (sub) / user_id is non-empty string - if (!payload.sub || typeof payload.sub !== 'string' || payload.sub.trim().length === 0) { - return null; - } - - return payload; + if (typeof payload.sub !== 'string' || payload.sub.trim().isEmpty) return null; + if (typeof payload.exp !== 'number') return null; + return payload as FirebaseTokenPayload; } catch { return null; } } +interface OwnedRide { + objectKey?: string; +} + +type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid'; + /** - * Decodes a base64url-encoded string + * 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. */ -function decodeBase64Url(input: string): string { - let base64 = input.replace(/-/g, '+').replace(/_/g, '/'); - while (base64.length % 4 !== 0) { - base64 += '='; +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', + }, + }); + + if (response.status === 401 || response.status === 403) return 'unauthorized'; + if (response.status === 404) return 'not-found'; + if (!response.ok) { + console.error('Firestore ownership lookup failed', response.status); + return 'unauthorized'; } - return atob(base64); + + const document = (await response.json()) as FirestoreDocument; + const fields = document.fields ?? {}; + const ownerId = firestoreString(fields.ownerId); + const storedId = firestoreString(fields.id); + const objectKey = firestoreString(fields.telemetryBundlePath) ?? firestoreString(fields.telemetryBundle?.mapValue?.fields?.objectKey); + + 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'; + + return { objectKey }; +} + +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; +} + +function encodeFirestorePath(path: string): string { + return path.split('/').map((segment) => encodeURIComponent(segment)).join('/'); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, + }); } -/** - * Converts an ArrayBuffer to a hex string - */ function bufferToHex(buffer: ArrayBuffer): string { - return Array.from(new Uint8Array(buffer)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); + return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, '0')).join(''); } -/** - * Validates ride ID string to prevent path traversal or malformed paths - */ function isValidRideId(rideId: string): boolean { - // Pattern: alphanumeric, underscores, hyphens, min 3 chars, max 100 chars - const regex = /^[a-zA-Z0-9_-]{3,100}$/; - return regex.test(rideId) && !rideId.includes('..') && !rideId.includes('/') && !rideId.includes('\\'); + return /^[a-zA-Z0-9_-]{3,100}$/.test(rideId) && !rideId.includes('..') && !rideId.includes('/') && !rideId.includes('\\'); }