From 11f9ce227cbacc9e206c52bcc545e7cdf3698bbd Mon Sep 17 00:00:00 2001 From: Saurav Kumar Date: Mon, 21 Sep 2026 16:56:19 +0530 Subject: [PATCH 1/5] fix(cli): increase projectId entropy to prevent enumeration attacks --- packages/crosscode/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 70da55956f739a7a5c704d20ae6d973242c5dbdf Mon Sep 17 00:00:00 2001 From: Saurav Kumar Date: Mon, 21 Sep 2026 16:58:53 +0530 Subject: [PATCH 2/5] fix(mobile): use expo-secure-store for sensitive auth data --- apps/mobile/lib/secure-storage.ts | 13 +++++++++++++ apps/mobile/package.json | 1 + apps/mobile/store/auth.store.ts | 4 ++-- apps/mobile/store/connection.store.ts | 4 ++-- 4 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/lib/secure-storage.ts diff --git a/apps/mobile/lib/secure-storage.ts b/apps/mobile/lib/secure-storage.ts new file mode 100644 index 0000000..c6d245a --- /dev/null +++ b/apps/mobile/lib/secure-storage.ts @@ -0,0 +1,13 @@ +import * as SecureStore from "expo-secure-store" + +export const secureStorage = { + getItem: async (name: string): Promise => { + return await SecureStore.getItemAsync(name) + }, + setItem: async (name: string, value: string): Promise => { + await SecureStore.setItemAsync(name, value) + }, + removeItem: async (name: string): Promise => { + await SecureStore.deleteItemAsync(name) + }, +} 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..39c0761 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,7 @@ export const useConnections = create()( }), { name: "crosscode-connections", - storage: createJSONStorage(() => AsyncStorage) + storage: createJSONStorage(() => secureStorage) } ) ) \ No newline at end of file From 7a0c617f155c3fbe4916633bab18cbaf56f5fdf7 Mon Sep 17 00:00:00 2001 From: Saurav Kumar Date: Mon, 21 Sep 2026 16:59:06 +0530 Subject: [PATCH 3/5] feat(tunnel): implement proxy request rate limiting to prevent local DDOS --- packages/tunnel-server/src/proxy.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/tunnel-server/src/proxy.ts b/packages/tunnel-server/src/proxy.ts index ca64ff7..a75148c 100644 --- a/packages/tunnel-server/src/proxy.ts +++ b/packages/tunnel-server/src/proxy.ts @@ -13,6 +13,21 @@ 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 rateLimits = new Map() + +function checkRateLimit(projectId: string): boolean { + const now = Date.now() + let limit = rateLimits.get(projectId) + if (!limit || now > limit.resetAt) { + limit = { count: 0, resetAt: now + RATE_LIMIT_WINDOW } + rateLimits.set(projectId, limit) + } + limit.count++ + return limit.count <= MAX_REQUESTS_PER_WINDOW +} + export function handleProxy(req: IncomingMessage, res: ServerResponse): void { const url = req.url || "/" const method = req.method || "GET" @@ -48,6 +63,13 @@ export function handleProxy(req: IncomingMessage, res: ServerResponse): void { return } + if (!checkRateLimit(projectId)) { + logger.warn("Rate limit exceeded for tunnel", { projectId, path, method }) + res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "60" }) + res.end(JSON.stringify({ error: "Too many requests. Please try again later." })) + return + } + const reqId = generateReqId() recordRequestStart(projectId) const hasAuth = !!req.headers["authorization"] From 8377103f04674dce351137cb0a56630aea7c6a46 Mon Sep 17 00:00:00 2001 From: Saurav Kumar Date: Mon, 21 Sep 2026 17:38:13 +0530 Subject: [PATCH 4/5] chore: sync lockfile for secure-store --- pnpm-lock.yaml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) 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): From a28d6013cea71218ac00cae5ce9e65a8d97e1654 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Mon, 21 Sep 2026 17:56:52 +0530 Subject: [PATCH 5/5] fix(security): scope rate limit per client, add secure-store fallback and migration --- apps/mobile/lib/secure-storage.ts | 27 ++++++++++++++++++++++++--- apps/mobile/store/connection.store.ts | 7 ++++++- packages/tunnel-server/src/proxy.ts | 23 +++++++++++++++++------ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/mobile/lib/secure-storage.ts b/apps/mobile/lib/secure-storage.ts index c6d245a..fbc2913 100644 --- a/apps/mobile/lib/secure-storage.ts +++ b/apps/mobile/lib/secure-storage.ts @@ -1,13 +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 => { - return await SecureStore.getItemAsync(name) + 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 => { - await SecureStore.setItemAsync(name, value) + 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 => { - await SecureStore.deleteItemAsync(name) + if (await availability) { + await SecureStore.deleteItemAsync(name).catch(() => undefined) + } + await AsyncStorage.removeItem(name).catch(() => undefined) }, } diff --git a/apps/mobile/store/connection.store.ts b/apps/mobile/store/connection.store.ts index 39c0761..e30a3f0 100644 --- a/apps/mobile/store/connection.store.ts +++ b/apps/mobile/store/connection.store.ts @@ -77,7 +77,12 @@ export const useConnections = create()( }), { name: "crosscode-connections", - storage: createJSONStorage(() => secureStorage) + 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/tunnel-server/src/proxy.ts b/packages/tunnel-server/src/proxy.ts index a75148c..86ba636 100644 --- a/packages/tunnel-server/src/proxy.ts +++ b/packages/tunnel-server/src/proxy.ts @@ -15,17 +15,26 @@ import { createQuestionPushObserver, createSsePushObserver } from "./push-events 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(projectId: string): boolean { +function checkRateLimit(key: string): { allowed: boolean; retryAfter: number } { const now = Date.now() - let limit = rateLimits.get(projectId) + 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(projectId, limit) + rateLimits.set(key, limit) } limit.count++ - return limit.count <= MAX_REQUESTS_PER_WINDOW + 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 { @@ -63,9 +72,11 @@ export function handleProxy(req: IncomingMessage, res: ServerResponse): void { return } - if (!checkRateLimit(projectId)) { + 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": "60" }) + 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 }