diff --git a/apps/mobile/lib/secure-storage.ts b/apps/mobile/lib/secure-storage.ts new file mode 100644 index 0000000..fbc2913 --- /dev/null +++ b/apps/mobile/lib/secure-storage.ts @@ -0,0 +1,34 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" +import * as SecureStore from "expo-secure-store" + +const availability: Promise = SecureStore.isAvailableAsync().catch(() => false) + +export const secureStorage = { + getItem: async (name: string): Promise => { + if (await availability) { + const value = await SecureStore.getItemAsync(name) + if (value !== null) return value + const legacy = await AsyncStorage.getItem(name) + if (legacy !== null) { + await SecureStore.setItemAsync(name, legacy).catch(() => undefined) + await AsyncStorage.removeItem(name).catch(() => undefined) + } + return legacy + } + return await AsyncStorage.getItem(name) + }, + setItem: async (name: string, value: string): Promise => { + if (await availability) { + await SecureStore.setItemAsync(name, value) + await AsyncStorage.removeItem(name).catch(() => undefined) + return + } + await AsyncStorage.setItem(name, value) + }, + removeItem: async (name: string): Promise => { + if (await availability) { + await SecureStore.deleteItemAsync(name).catch(() => undefined) + } + await AsyncStorage.removeItem(name).catch(() => undefined) + }, +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b91d7e0..7dc4d34 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -32,6 +32,7 @@ "expo-linking": "^57.0.5", "expo-notifications": "~57.0.10", "expo-router": "^57.0.11", + "expo-secure-store": "~57.0.0", "expo-speech-recognition": "^56.0.1", "expo-splash-screen": "~57.0.5", "expo-status-bar": "~57.0.1", diff --git a/apps/mobile/store/auth.store.ts b/apps/mobile/store/auth.store.ts index e9c7937..529d7dd 100644 --- a/apps/mobile/store/auth.store.ts +++ b/apps/mobile/store/auth.store.ts @@ -1,6 +1,6 @@ -import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" +import { secureStorage } from "../lib/secure-storage" type User = { id: string @@ -33,7 +33,7 @@ export const useAuth = create()( }), { name: "crosscode-auth", - storage: createJSONStorage(() => AsyncStorage), + storage: createJSONStorage(() => secureStorage), } ) ) diff --git a/apps/mobile/store/connection.store.ts b/apps/mobile/store/connection.store.ts index 2f937fa..e30a3f0 100644 --- a/apps/mobile/store/connection.store.ts +++ b/apps/mobile/store/connection.store.ts @@ -1,6 +1,6 @@ -import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" +import { secureStorage } from "../lib/secure-storage" let nextID = 1 @@ -77,7 +77,12 @@ export const useConnections = create()( }), { name: "crosscode-connections", - storage: createJSONStorage(() => AsyncStorage) + storage: createJSONStorage(() => secureStorage), + partialize: (state) => ({ + connections: state.connections.map(({ healthy, ...rest }) => rest), + current: state.current, + activeConnections: state.activeConnections, + }), } ) ) \ No newline at end of file diff --git a/packages/crosscode/src/config.ts b/packages/crosscode/src/config.ts index d583ffe..75ba9b6 100644 --- a/packages/crosscode/src/config.ts +++ b/packages/crosscode/src/config.ts @@ -105,7 +105,7 @@ export function ensureSessionToken(config: Config, project?: ProjectConfig): str export function ensureProjectId(config: Config, project?: ProjectConfig): string { const target = project ?? getProjectConfig(config) if (!target.projectId) { - target.projectId = crypto.randomBytes(4).toString("hex") + target.projectId = crypto.randomBytes(16).toString("hex") saveProjectConfig(config) logCrosscode(`Project ID generated for ${process.cwd()}: ${target.projectId}`) } else { diff --git a/packages/tunnel-server/src/proxy.ts b/packages/tunnel-server/src/proxy.ts index ca64ff7..86ba636 100644 --- a/packages/tunnel-server/src/proxy.ts +++ b/packages/tunnel-server/src/proxy.ts @@ -13,6 +13,30 @@ import type { WebSocket } from "ws" import { logger } from "./logger.js" import { createQuestionPushObserver, createSsePushObserver } from "./push-events.js" +const RATE_LIMIT_WINDOW = 60_000 +const MAX_REQUESTS_PER_WINDOW = 120 +const MAX_RATE_LIMIT_KEYS = 10_000 +const rateLimits = new Map() + +function checkRateLimit(key: string): { allowed: boolean; retryAfter: number } { + const now = Date.now() + let limit = rateLimits.get(key) + if (!limit || now > limit.resetAt) { + if (rateLimits.size >= MAX_RATE_LIMIT_KEYS) { + for (const [k, v] of rateLimits) { + if (v.resetAt <= now) rateLimits.delete(k) + } + } + limit = { count: 0, resetAt: now + RATE_LIMIT_WINDOW } + rateLimits.set(key, limit) + } + limit.count++ + return { + allowed: limit.count <= MAX_REQUESTS_PER_WINDOW, + retryAfter: Math.max(1, Math.ceil((limit.resetAt - now) / 1000)), + } +} + export function handleProxy(req: IncomingMessage, res: ServerResponse): void { const url = req.url || "/" const method = req.method || "GET" @@ -48,6 +72,15 @@ export function handleProxy(req: IncomingMessage, res: ServerResponse): void { return } + const clientIp = req.socket.remoteAddress || "unknown" + const limit = checkRateLimit(`${projectId}:${clientIp}`) + if (!limit.allowed) { + logger.warn("Rate limit exceeded for tunnel", { projectId, path, method }) + res.writeHead(429, { "Content-Type": "application/json", "Retry-After": String(limit.retryAfter) }) + res.end(JSON.stringify({ error: "Too many requests. Please try again later." })) + return + } + const reqId = generateReqId() recordRequestStart(projectId) const hasAuth = !!req.headers["authorization"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0256262..82dd260 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: expo-router: specifier: ^57.0.11 version: 57.0.11(l2b7lcph2c7zbw7nxiipcduw4i) + expo-secure-store: + specifier: ~57.0.0 + version: 57.0.4(expo@57.0.11) expo-speech-recognition: specifier: ^56.0.1 version: 56.0.1(expo@57.0.11)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) @@ -7599,6 +7602,11 @@ packages: react-server-dom-webpack: optional: true + expo-secure-store@57.0.4: + resolution: {integrity: sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA==} + peerDependencies: + expo: '*' + expo-server@57.0.1: resolution: {integrity: sha512-sBfVDH6dmKVHZxqUxbfkzS00PZELMZt1IpnHKxcOTMZtR/t7CtRAFrbXcisG+EyzeqHSVDacZT+1tbYfZt5D8w==} engines: {node: '>=20.16.0'} @@ -15970,9 +15978,7 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.74.89': optional: true @@ -19334,7 +19340,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.12 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) @@ -19357,7 +19363,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3(supports-color@8.1.1) @@ -19372,14 +19378,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -19394,7 +19400,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -19871,6 +19877,10 @@ snapshots: - react-native-worklets - supports-color + expo-secure-store@57.0.4(expo@57.0.11): + dependencies: + expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-server@57.0.1: {} expo-speech-recognition@56.0.1(expo@57.0.11)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3):