fix: correct Worker TypeScript token subject validation

This commit is contained in:
bponders12-cell 2026-09-01 21:19:15 -04:00
parent 9de553d596
commit 03932d573f
1 changed files with 22 additions and 121 deletions

View File

@ -28,9 +28,7 @@ const jwks = createRemoteJWKSet(FIREBASE_JWKS);
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') { if (request.method === 'OPTIONS') return new Response(null, { headers: CORS_HEADERS });
return new Response(null, { headers: CORS_HEADERS });
}
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
@ -38,117 +36,55 @@ export default {
try { try {
if (path === '/' || path === '/health') { if (path === '/' || path === '/health') {
return jsonResponse({ return jsonResponse({ status: 'ok', service: 'motorsignal-r2-gateway', project: projectId, environment: env.ENVIRONMENT || 'production' });
status: 'ok',
service: 'motorsignal-r2-gateway',
project: projectId,
environment: env.ENVIRONMENT || 'production',
});
} }
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401);
return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401);
}
const idToken = authHeader.substring(7).trim(); const idToken = authHeader.substring(7).trim();
const authUser = await verifyFirebaseToken(idToken, projectId); const authUser = await verifyFirebaseToken(idToken, projectId);
if (!authUser?.sub) { if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401);
return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401);
}
const authenticatedUid = authUser.sub; const authenticatedUid = authUser.sub;
if (request.method === 'PUT' && path === '/r2/upload') { if (request.method === 'PUT' && path === '/r2/upload') {
const remoteRideId = request.headers.get('X-Remote-Ride-Id'); const remoteRideId = request.headers.get('X-Remote-Ride-Id');
if (!remoteRideId || !isValidRideId(remoteRideId)) { if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400);
return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400);
}
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
const clientSha256 = request.headers.get('X-Bundle-Sha256'); const clientSha256 = request.headers.get('X-Bundle-Sha256');
const appVersion = request.headers.get('X-App-Version') || 'unknown'; const appVersion = request.headers.get('X-App-Version') || 'unknown';
if (!request.body) return jsonResponse({ error: 'Missing binary payload body' }, 400); if (!request.body) return jsonResponse({ error: 'Missing binary payload body' }, 400);
const payloadBuffer = await request.arrayBuffer(); const payloadBuffer = await request.arrayBuffer();
if (payloadBuffer.byteLength === 0) return jsonResponse({ error: 'Empty payload body' }, 400); if (payloadBuffer.byteLength === 0) return jsonResponse({ error: 'Empty payload body' }, 400);
const computedSha256 = bufferToHex(await crypto.subtle.digest('SHA-256', payloadBuffer));
const computedHashBuffer = await crypto.subtle.digest('SHA-256', payloadBuffer);
const computedSha256 = bufferToHex(computedHashBuffer);
if (clientSha256 && clientSha256.trim() && clientSha256.trim().toLowerCase() !== computedSha256) { if (clientSha256 && clientSha256.trim() && clientSha256.trim().toLowerCase() !== computedSha256) {
return jsonResponse({ return jsonResponse({ error: 'Payload integrity check failed: SHA-256 mismatch', expected: clientSha256, computed: computedSha256 }, 422);
error: 'Payload integrity check failed: SHA-256 mismatch',
expected: clientSha256,
computed: computedSha256,
}, 422);
} }
const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, { const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, {
httpMetadata: { contentType: 'application/zip' }, httpMetadata: { contentType: 'application/zip' },
customMetadata: { customMetadata: { uid: authenticatedUid, remoteRideId, sha256: computedSha256, appVersion, uploadedAt: new Date().toISOString() },
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(),
}); });
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') { if (request.method === 'POST' && path === '/r2/upload-url') {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>; const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const remoteRideId = body.remoteRideId as string | undefined; const remoteRideId = body.remoteRideId as string | undefined;
if (!remoteRideId || !isValidRideId(remoteRideId)) { if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400);
return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400);
}
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
return jsonResponse({ return jsonResponse({ uploadEndpoint: `${url.origin}/r2/upload`, objectKey, method: 'PUT', headers: { 'Content-Type': 'application/zip', 'X-Remote-Ride-Id': remoteRideId } });
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') { if (request.method === 'GET' && path === '/r2/download') {
const remoteRideId = url.searchParams.get('rideId'); const remoteRideId = url.searchParams.get('rideId');
if (!remoteRideId || !isValidRideId(remoteRideId)) { if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
}
// Defense in depth: Firebase token verification establishes identity, const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken);
// 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 === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403);
if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404); if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404);
if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409); if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409);
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; 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); const object = await env.R2_BUCKET.get(objectKey);
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404); if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404);
@ -160,13 +96,11 @@ export default {
const sha256 = object.customMetadata?.sha256; const sha256 = object.customMetadata?.sha256;
if (sha256) headers.set('X-MotorSignal-Sha256', sha256); if (sha256) headers.set('X-MotorSignal-Sha256', sha256);
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
return new Response(object.body, { status: 200, headers }); return new Response(object.body, { status: 200, headers });
} }
return jsonResponse({ error: 'Endpoint not found' }, 404); return jsonResponse({ error: 'Endpoint not found' }, 404);
} catch (err: unknown) { } catch (err: unknown) {
// Do not expose internal exception details to callers in production.
console.error('MotorSignal Worker exception', err); console.error('MotorSignal Worker exception', err);
return jsonResponse({ error: 'Internal Worker Exception' }, 500); return jsonResponse({ error: 'Internal Worker Exception' }, 500);
} }
@ -175,15 +109,13 @@ export default {
async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise<FirebaseTokenPayload | null> { async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise<FirebaseTokenPayload | null> {
try { try {
const expectedIssuer = `https://securetoken.google.com/${expectedProjectId}`;
const { payload } = await jwtVerify(token, jwks, { const { payload } = await jwtVerify(token, jwks, {
issuer: expectedIssuer, issuer: `https://securetoken.google.com/${expectedProjectId}`,
audience: expectedProjectId, audience: expectedProjectId,
algorithms: ['RS256'], algorithms: ['RS256'],
clockTolerance: 60, clockTolerance: 60,
}); });
if (typeof payload.sub !== 'string' || payload.sub.trim().length === 0) return null;
if (typeof payload.sub !== 'string' || payload.sub.trim().isEmpty) return null;
if (typeof payload.exp !== 'number') return null; if (typeof payload.exp !== 'number') return null;
return payload as FirebaseTokenPayload; return payload as FirebaseTokenPayload;
} catch { } catch {
@ -191,33 +123,13 @@ async function verifyFirebaseToken(token: string, expectedProjectId: string): Pr
} }
} }
interface OwnedRide { interface OwnedRide { objectKey?: string; }
objectKey?: string;
}
type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid'; type RideAuthorizationResult = OwnedRide | 'unauthorized' | 'not-found' | 'invalid';
/** async function fetchOwnedRide(projectId: string, uid: string, remoteRideId: string, idToken: string): Promise<RideAuthorizationResult> {
* 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<RideAuthorizationResult> {
const documentPath = encodeFirestorePath(`users/${uid}/rides/${remoteRideId}`); const documentPath = encodeFirestorePath(`users/${uid}/rides/${remoteRideId}`);
const endpoint = `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/databases/(default)/documents/${documentPath}`; 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 === 401 || response.status === 403) return 'unauthorized';
if (response.status === 404) return 'not-found'; if (response.status === 404) return 'not-found';
@ -234,20 +146,12 @@ async function fetchOwnedRide(
if (ownerId !== uid) return 'unauthorized'; if (ownerId !== uid) return 'unauthorized';
if (storedId && storedId !== remoteRideId) return 'invalid'; if (storedId && storedId !== remoteRideId) return 'invalid';
if (!objectKey) return 'invalid'; if (!objectKey || objectKey !== `users/${uid}/rides/${remoteRideId}/telemetry.zip`) return 'invalid';
if (objectKey !== `users/${uid}/rides/${remoteRideId}/telemetry.zip`) return 'invalid';
return { objectKey }; return { objectKey };
} }
interface FirestoreValue { interface FirestoreValue { stringValue?: string; mapValue?: { fields?: Record<string, FirestoreValue> }; }
stringValue?: string; interface FirestoreDocument { fields?: Record<string, FirestoreValue>; }
mapValue?: { fields?: Record<string, FirestoreValue> };
}
interface FirestoreDocument {
fields?: Record<string, FirestoreValue>;
}
function firestoreString(value: FirestoreValue | undefined): string | undefined { function firestoreString(value: FirestoreValue | undefined): string | undefined {
return typeof value?.stringValue === 'string' ? value.stringValue : 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 { function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), { status, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } });
status,
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
});
} }
function bufferToHex(buffer: ArrayBuffer): string { function bufferToHex(buffer: ArrayBuffer): string {