security: cryptographically verify Firebase tokens and harden R2 downloads
This commit is contained in:
parent
ab6d823245
commit
9de553d596
|
|
@ -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.
|
* Secure authorization gateway for MotorSignal telemetry bundles on Cloudflare R2.
|
||||||
* Cryptographically validates Firebase Authentication ID tokens server-side,
|
* Firebase ID tokens are cryptographically verified before any R2 access.
|
||||||
* enforces SHA-256 payload integrity, and derives user-isolated object keys.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
|
|
@ -11,16 +12,9 @@ export interface Env {
|
||||||
ENVIRONMENT: string;
|
ENVIRONMENT: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FirebaseTokenPayload {
|
interface FirebaseTokenPayload extends JWTPayload {
|
||||||
iss: string;
|
user_id?: string;
|
||||||
aud: string;
|
|
||||||
auth_time: number;
|
|
||||||
user_id: string;
|
|
||||||
sub: string;
|
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
email?: string;
|
email?: string;
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CORS_HEADERS = {
|
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',
|
'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 {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
// 1. Handle CORS Preflight
|
|
||||||
if (request.method === 'OPTIONS') {
|
if (request.method === 'OPTIONS') {
|
||||||
return new Response(null, { headers: CORS_HEADERS });
|
return new Response(null, { headers: CORS_HEADERS });
|
||||||
}
|
}
|
||||||
|
|
@ -41,252 +37,237 @@ export default {
|
||||||
const projectId = env.FIREBASE_PROJECT_ID || 'motoapp-d22e0';
|
const projectId = env.FIREBASE_PROJECT_ID || 'motoapp-d22e0';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Health check endpoint
|
|
||||||
if (path === '/' || path === '/health') {
|
if (path === '/' || path === '/health') {
|
||||||
return new Response(
|
return jsonResponse({
|
||||||
JSON.stringify({
|
status: 'ok',
|
||||||
status: 'ok',
|
service: 'motorsignal-r2-gateway',
|
||||||
service: 'motorsignal-r2-gateway',
|
project: projectId,
|
||||||
project: projectId,
|
environment: env.ENVIRONMENT || 'production',
|
||||||
environment: env.ENVIRONMENT || 'production',
|
});
|
||||||
}),
|
|
||||||
{ headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Authenticate Firebase ID Token
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
return new Response(JSON.stringify({ error: 'Missing or malformed Authorization header' }), {
|
return jsonResponse({ error: 'Missing or malformed Authorization header' }, 401);
|
||||||
status: 401,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 || !authUser.sub) {
|
if (!authUser?.sub) {
|
||||||
return new Response(JSON.stringify({ error: 'Invalid, expired, or unauthorized Firebase ID token' }), {
|
return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401);
|
||||||
status: 401,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticatedUid = authUser.sub;
|
const authenticatedUid = authUser.sub;
|
||||||
|
|
||||||
// 4. Route: Direct PUT Upload (/r2/upload)
|
|
||||||
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 new Response(JSON.stringify({ error: 'Invalid or missing X-Remote-Ride-Id header' }), {
|
return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400);
|
||||||
status: 400,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strictly derive object path server-side to prevent cross-user pollution
|
|
||||||
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) {
|
if (!request.body) return jsonResponse({ error: 'Missing binary payload body' }, 400);
|
||||||
return new Response(JSON.stringify({ error: 'Missing binary payload body' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read payload bytes for SHA-256 verification and streaming
|
|
||||||
const payloadBuffer = await request.arrayBuffer();
|
const payloadBuffer = await request.arrayBuffer();
|
||||||
if (payloadBuffer.byteLength === 0) {
|
if (payloadBuffer.byteLength === 0) return jsonResponse({ error: 'Empty payload body' }, 400);
|
||||||
return new Response(JSON.stringify({ error: 'Empty payload body' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute and verify SHA-256 checksum
|
|
||||||
const computedHashBuffer = await crypto.subtle.digest('SHA-256', payloadBuffer);
|
const computedHashBuffer = await crypto.subtle.digest('SHA-256', payloadBuffer);
|
||||||
const computedSha256 = bufferToHex(computedHashBuffer);
|
const computedSha256 = bufferToHex(computedHashBuffer);
|
||||||
|
|
||||||
if (clientSha256 && clientSha256.trim().isNotEmpty && clientSha256 !== computedSha256) {
|
if (clientSha256 && clientSha256.trim() && clientSha256.trim().toLowerCase() !== computedSha256) {
|
||||||
return new Response(
|
return jsonResponse({
|
||||||
JSON.stringify({
|
error: 'Payload integrity check failed: SHA-256 mismatch',
|
||||||
error: 'Payload integrity check failed: SHA-256 mismatch',
|
expected: clientSha256,
|
||||||
expected: clientSha256,
|
computed: computedSha256,
|
||||||
computed: computedSha256,
|
}, 422);
|
||||||
}),
|
|
||||||
{ status: 422, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store directly into Cloudflare R2
|
|
||||||
const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, {
|
const r2Object = await env.R2_BUCKET.put(objectKey, payloadBuffer, {
|
||||||
httpMetadata: {
|
httpMetadata: { contentType: 'application/zip' },
|
||||||
contentType: 'application/zip',
|
|
||||||
},
|
|
||||||
customMetadata: {
|
customMetadata: {
|
||||||
uid: authenticatedUid,
|
uid: authenticatedUid,
|
||||||
remoteRideId: remoteRideId,
|
remoteRideId,
|
||||||
sha256: computedSha256,
|
sha256: computedSha256,
|
||||||
appVersion: appVersion,
|
appVersion,
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return jsonResponse({
|
||||||
JSON.stringify({
|
success: true,
|
||||||
success: true,
|
objectKey,
|
||||||
objectKey: objectKey,
|
size: r2Object.size,
|
||||||
size: r2Object.size,
|
etag: r2Object.etag,
|
||||||
etag: r2Object.etag,
|
sha256: computedSha256,
|
||||||
sha256: computedSha256,
|
uploadedAt: new Date().toISOString(),
|
||||||
uploadedAt: new Date().toISOString(),
|
});
|
||||||
}),
|
|
||||||
{ status: 200, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Route: Presigned / Direct Upload Request Metadata (/r2/upload-url)
|
|
||||||
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 new Response(JSON.stringify({ error: 'Invalid remoteRideId in request payload' }), {
|
return jsonResponse({ error: 'Invalid remoteRideId in request payload' }, 400);
|
||||||
status: 400,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
||||||
|
return jsonResponse({
|
||||||
return new Response(
|
uploadEndpoint: `${url.origin}/r2/upload`,
|
||||||
JSON.stringify({
|
objectKey,
|
||||||
uploadEndpoint: `${url.origin}/r2/upload`,
|
method: 'PUT',
|
||||||
objectKey: objectKey,
|
headers: {
|
||||||
method: 'PUT',
|
'Content-Type': 'application/zip',
|
||||||
headers: {
|
'X-Remote-Ride-Id': remoteRideId,
|
||||||
'Content-Type': 'application/zip',
|
},
|
||||||
'X-Remote-Ride-Id': remoteRideId,
|
});
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Route: Download Telemetry Bundle (/r2/download)
|
|
||||||
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 new Response(JSON.stringify({ error: 'Invalid rideId parameter' }), {
|
return jsonResponse({ error: 'Invalid rideId parameter' }, 400);
|
||||||
status: 400,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;
|
||||||
const object = await env.R2_BUCKET.get(objectKey);
|
if (ride.objectKey && ride.objectKey !== objectKey) {
|
||||||
|
return jsonResponse({ error: 'Telemetry object ownership metadata mismatch' }, 409);
|
||||||
if (!object) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Telemetry bundle not found in R2' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const object = await env.R2_BUCKET.get(objectKey);
|
||||||
|
if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404);
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
object.writeHttpMetadata(headers);
|
object.writeHttpMetadata(headers);
|
||||||
headers.set('etag', object.httpEtag);
|
headers.set('etag', object.httpEtag);
|
||||||
headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`);
|
headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`);
|
||||||
for (const [k, v] of Object.entries(CORS_HEADERS)) {
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||||
headers.set(k, v);
|
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 jsonResponse({ error: 'Endpoint not found' }, 404);
|
||||||
return new Response(JSON.stringify({ error: 'Endpoint not found' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
// Do not expose internal exception details to callers in production.
|
||||||
return new Response(JSON.stringify({ error: 'Internal Worker Exception', details: msg }), {
|
console.error('MotorSignal Worker exception', err);
|
||||||
status: 500,
|
return jsonResponse({ error: 'Internal Worker Exception' }, 500);
|
||||||
headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the cryptographic structure and claims of a Firebase ID token.
|
|
||||||
*/
|
|
||||||
async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise<FirebaseTokenPayload | null> {
|
async function verifyFirebaseToken(token: string, expectedProjectId: string): Promise<FirebaseTokenPayload | null> {
|
||||||
try {
|
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/<projectId>
|
|
||||||
const expectedIssuer = `https://securetoken.google.com/${expectedProjectId}`;
|
const expectedIssuer = `https://securetoken.google.com/${expectedProjectId}`;
|
||||||
if (payload.iss !== expectedIssuer) {
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
return null;
|
issuer: expectedIssuer,
|
||||||
}
|
audience: expectedProjectId,
|
||||||
|
algorithms: ['RS256'],
|
||||||
|
clockTolerance: 60,
|
||||||
|
});
|
||||||
|
|
||||||
// 4. Verify subject (sub) / user_id is non-empty string
|
if (typeof payload.sub !== 'string' || payload.sub.trim().isEmpty) return null;
|
||||||
if (!payload.sub || typeof payload.sub !== 'string' || payload.sub.trim().length === 0) {
|
if (typeof payload.exp !== 'number') return null;
|
||||||
return null;
|
return payload as FirebaseTokenPayload;
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 {
|
async function fetchOwnedRide(
|
||||||
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
projectId: string,
|
||||||
while (base64.length % 4 !== 0) {
|
uid: string,
|
||||||
base64 += '=';
|
remoteRideId: string,
|
||||||
|
idToken: string,
|
||||||
|
): Promise<RideAuthorizationResult> {
|
||||||
|
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<string, FirestoreValue> };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FirestoreDocument {
|
||||||
|
fields?: Record<string, FirestoreValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function bufferToHex(buffer: ArrayBuffer): string {
|
||||||
return Array.from(new Uint8Array(buffer))
|
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
.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 {
|
function isValidRideId(rideId: string): boolean {
|
||||||
// Pattern: alphanumeric, underscores, hyphens, min 3 chars, max 100 chars
|
return /^[a-zA-Z0-9_-]{3,100}$/.test(rideId) && !rideId.includes('..') && !rideId.includes('/') && !rideId.includes('\\');
|
||||||
const regex = /^[a-zA-Z0-9_-]{3,100}$/;
|
|
||||||
return regex.test(rideId) && !rideId.includes('..') && !rideId.includes('/') && !rideId.includes('\\');
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue