diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index d914c32..0a124a7 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -32,6 +32,10 @@ yarn-error.* # local env files .env*.local +.env +.env.production +.env.development +.env.staging # typescript *.tsbuildinfo diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index d73503a..ee5aac0 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -62,8 +62,8 @@ module.exports = ({ config }) => { config.updates = { ...(config.updates || {}), url: "https://u.expo.dev/3a001439-9712-4716-9865-47413eac1995", - checkAutomatically: "ON_LOAD", - fallbackToCacheTimeout: 0, + checkAutomatically: "ON_ERROR_RECOVERY", + fallbackToCacheTimeout: 30000, }; config.runtimeVersion = config.runtimeVersion || { policy: "appVersion" }; diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 609fb87..53e81a8 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -49,7 +49,9 @@ export default function RootLayout() { React.useEffect(() => { if (isUpdatePending) { - Updates.reloadAsync() + // Never auto-reload mid-session: a pending OTA is applied on next + // cold start / manual update check instead of killing streaming state. + Updates.reloadAsync().catch(() => undefined) } }, [isUpdatePending]) @@ -69,9 +71,11 @@ export default function RootLayout() { let timer: ReturnType | undefined const schedule = () => { if (timer) clearTimeout(timer) + // Throttled so per-keystroke store writes (drafts/streaming) don't + // rebuild widgets multiple times per second. timer = setTimeout(() => { - refreshWidgets() - }, 800) + refreshWidgets().catch(() => undefined) + }, 5000) } const unsubSessions = useSessions.subscribe(schedule) const unsubConnections = useConnections.subscribe(schedule) diff --git a/apps/mobile/app/connect.tsx b/apps/mobile/app/connect.tsx index ee6abfb..7f221ee 100644 --- a/apps/mobile/app/connect.tsx +++ b/apps/mobile/app/connect.tsx @@ -12,9 +12,14 @@ import XIcon from "lucide-react-native/dist/esm/icons/x" import { cn, getAuthHeader } from "@/lib/utils" import { THEME } from "@/lib/theme" import { useColorScheme } from "nativewind" +import { consumePendingConnection, peekPendingConnection } from "@/lib/pending-connection" +import { validateConnectionUrl, validateAuthToken } from "@/lib/security" export default function Connect() { - const { url, token } = useLocalSearchParams<{ url: string, token: string }>() + const params = useLocalSearchParams<{ url?: string, token?: string }>() + const pending = peekPendingConnection() + const url = pending?.url ?? params.url ?? "" + const token = pending?.token ?? params.token ?? "" const { colorScheme } = useColorScheme() const router = useRouter() const insets = useSafeAreaInsets() @@ -30,22 +35,31 @@ export default function Connect() { setTesting(true) try { - const res = await fetch(`${url}/global/health`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token) - } - }) - if (res.ok) { - setTested({ - msg: "Remote server reachable", - error: false - }) - } else { - setTested({ - msg: "Remote server unreachable", - error: true + validateConnectionUrl(url) + validateAuthToken(token) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10000) + try { + const res = await fetch(`${url}/global/health`, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token) + }, + signal: controller.signal, }) + if (res.ok) { + setTested({ + msg: "Remote server reachable", + error: false + }) + } else { + setTested({ + msg: "Remote server unreachable", + error: true + }) + } + } finally { + clearTimeout(timer) } } catch { setTested({ @@ -58,6 +72,13 @@ export default function Connect() { }, [url, token]) function save() { + try { + validateConnectionUrl(url) + validateAuthToken(token) + } catch (error) { + setTested({ msg: error instanceof Error ? error.message : "Invalid connection", error: true }) + return + } const tier = useAuth.getState().user?.tier ?? "free" if (isAtTunnelLimit(tier, connections.length)) { void requestPaywall("connection_limit") @@ -68,6 +89,7 @@ export default function Connect() { name, token }) + consumePendingConnection() router.replace("/") } @@ -78,7 +100,10 @@ export default function Connect() { return ( - @@ -143,7 +168,10 @@ export default function Connect() { Select Model - {selectedId ? ( - - - {providerMap[selectedProviderId] ?? selectedProviderId} - {selectedId ? ` / ${selectedId}` : ""} + + {filtered.length} models + + + + + + + + + + Current model + + {currentModel ? ( + <> + + {currentModel.name} + + + {providerMap[currentModel.providerID] ?? currentModel.providerID} + {currentModel.family ? ` ยท ${currentModel.family}` : ""} + + + ) : ( + + No model selected + + )} + + {currentModel && ( + + + {currentModel.status} - ) : null} + )} - + @@ -269,64 +342,105 @@ export default function ModelsPage() { clearButtonMode="while-editing" /> + setFilterOpen((v) => !v)} + accessible + accessibilityRole="button" + accessibilityLabel="Toggle filters" + accessibilityHint="Shows or hides filter options" + accessibilityState={{ expanded: filterOpen, selected: filterOpen }} + className={cn( + "w-11 h-11 rounded-full border items-center justify-center", + filterOpen ? "bg-primary/10 border-primary" : "border-border" + )} + > + + + setSortOpen((v) => !v)} + accessible + accessibilityRole="button" + accessibilityLabel="Toggle sort options" + accessibilityHint="Shows or hides sort options" + accessibilityState={{ expanded: sortOpen, selected: sortOpen }} + className={cn( + "w-11 h-11 rounded-full border items-center justify-center", + sortOpen ? "bg-primary/10 border-primary" : "border-border" + )} + > + + - - - Filter: - {STATUS_OPTIONS.map((opt) => ( - setStatusFilter(opt.value)} - > - + Filter: + {STATUS_OPTIONS.map((opt) => ( + setStatusFilter(opt.value)} > - {opt.label} - - - ))} - + + {opt.label} + + + ))} + + )} - - - Sort: - {SORT_OPTIONS.map((opt) => ( - setSort(opt.value)} - > - + Sort: + {SORT_OPTIONS.map((opt) => ( + setSort(opt.value)} > - {opt.label} - - - ))} - - {filtered.length} models - - + + {opt.label} + + + ))} + + )} {models.length === 0 ? ( diff --git a/apps/mobile/components/link-options-modal.tsx b/apps/mobile/components/link-options-modal.tsx index 2692b81..f434b2f 100644 --- a/apps/mobile/components/link-options-modal.tsx +++ b/apps/mobile/components/link-options-modal.tsx @@ -9,6 +9,7 @@ import { Dialog } from "@/components/ui/dialog" import { Text } from "@/components/ui/text" import { Button } from "@/components/ui/button" import { THEME } from "@/lib/theme" +import { isSafeExternalUrl } from "@/lib/security" interface LinkOptionsModalProps { open: boolean @@ -35,9 +36,15 @@ function LinkOptionsModalInner({ open, url, onClose, theme }: LinkOptionsModalPr const handleOpen = async () => { if (!url) return + if (!isSafeExternalUrl(url)) { + handleClose() + return + } handleClose() try { - await Linking.openURL(url) + if (await Linking.canOpenURL(url)) { + await Linking.openURL(url) + } } catch {} } diff --git a/apps/mobile/lib/pending-connection.ts b/apps/mobile/lib/pending-connection.ts new file mode 100644 index 0000000..839674d --- /dev/null +++ b/apps/mobile/lib/pending-connection.ts @@ -0,0 +1,20 @@ +type PendingConnection = { + url: string + token: string +} + +let pending: PendingConnection | null = null + +export function setPendingConnection(conn: PendingConnection): void { + pending = conn +} + +export function consumePendingConnection(): PendingConnection | null { + const current = pending + pending = null + return current +} + +export function peekPendingConnection(): PendingConnection | null { + return pending +} diff --git a/apps/mobile/lib/secure-storage.ts b/apps/mobile/lib/secure-storage.ts index fbc2913..23ad2ed 100644 --- a/apps/mobile/lib/secure-storage.ts +++ b/apps/mobile/lib/secure-storage.ts @@ -5,29 +5,43 @@ const availability: Promise = SecureStore.isAvailableAsync().catch(() = 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) + try { + 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 legacy + } catch { + // Fall through to AsyncStorage so a corrupt/locked keystore + // never bricks the app. Secrets may be less protected here. } - return await AsyncStorage.getItem(name) + return await AsyncStorage.getItem(name).catch(() => null) }, setItem: async (name: string, value: string): Promise => { - if (await availability) { - await SecureStore.setItemAsync(name, value) - await AsyncStorage.removeItem(name).catch(() => undefined) - return + try { + if (await availability) { + await SecureStore.setItemAsync(name, value) + await AsyncStorage.removeItem(name).catch(() => undefined) + return + } + } catch { + // SecureStore throws (e.g. value > ~2KB on some devices). + // Fall back to AsyncStorage instead of silently losing data. } - await AsyncStorage.setItem(name, value) + await AsyncStorage.setItem(name, value).catch(() => undefined) }, removeItem: async (name: string): Promise => { - if (await availability) { - await SecureStore.deleteItemAsync(name).catch(() => undefined) + try { + if (await availability) { + await SecureStore.deleteItemAsync(name).catch(() => undefined) + } + } catch { + // ignore keystore errors, still clear the fallback below } await AsyncStorage.removeItem(name).catch(() => undefined) }, diff --git a/apps/mobile/lib/security.ts b/apps/mobile/lib/security.ts new file mode 100644 index 0000000..d8251bf --- /dev/null +++ b/apps/mobile/lib/security.ts @@ -0,0 +1,85 @@ +const TOKEN_RE = /^[A-Za-z0-9\-_.]{16,256}$/ +const ALLOWED_EXTERNAL_PROTOCOLS = new Set(["https:", "mailto:"]) + +export function isPrivateLanHost(host: string): boolean { + const h = host.toLowerCase() + if (h === "localhost" || h === "127.0.0.1" || h === "::1") return true + return ( + /^10\./.test(h) || + /^192\.168\./.test(h) || + /^172\.(1[6-9]|2\d|3[01])\./.test(h) + ) +} + +export function isBlockedMetadataHost(host: string): boolean { + const h = host.toLowerCase() + return h === "169.254.169.254" || h === "metadata.google.internal" +} + +/** Validate a tunnel / connection URL scanned from QR. Throws on invalid. */ +export function validateConnectionUrl(url: string): string { + if (typeof url !== "string" || url.length === 0 || url.length > 2048) { + throw new Error("Invalid server URL in QR code") + } + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new Error("Invalid server URL in QR code") + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("Invalid server URL in QR code") + } + if (isBlockedMetadataHost(parsed.hostname)) { + throw new Error("Invalid server URL in QR code") + } + if (parsed.protocol === "http:" && !isPrivateLanHost(parsed.hostname)) { + throw new Error("QR code must use https (http is only allowed for local network)") + } + return url +} + +export function validateAuthToken(token: string): string { + if (typeof token !== "string" || !TOKEN_RE.test(token)) { + throw new Error("Invalid token in QR code") + } + return token +} + +/** Validate the auth-server URL used for device-link claim. Pins to https. */ +export function validateServerUrl(serverUrl: string): string { + return validateConnectionUrl(serverUrl) +} + +/** Allowlist for opening AI/server-controlled links via Linking. */ +export function isSafeExternalUrl(href: string): boolean { + try { + const u = new URL(href) + if (!ALLOWED_EXTERNAL_PROTOCOLS.has(u.protocol)) return false + if (u.protocol === "https:" && isBlockedMetadataHost(u.hostname)) return false + return true + } catch { + return false + } +} + +/** + * fetch wrapper that enforces https (except loopback/LAN), adds a timeout, + * and never logs secrets. Use for all tunnel / account requests. + */ +export async function secureFetch( + baseUrl: string, + path: string, + init: RequestInit = {}, + timeoutMs = 15000 +): Promise { + validateConnectionUrl(baseUrl) + const url = `${baseUrl.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}` + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} diff --git a/apps/mobile/lib/updates.ts b/apps/mobile/lib/updates.ts index 3f628a6..d9ded1c 100644 --- a/apps/mobile/lib/updates.ts +++ b/apps/mobile/lib/updates.ts @@ -20,9 +20,13 @@ export function getUpdateInfo() { export async function checkAndApplyUpdate(): Promise { if (__DEV__) return "unavailable-in-dev" if (!Updates.isEnabled) return "disabled" - const check = await Updates.checkForUpdateAsync() - if (!check.isAvailable) return "up-to-date" - await Updates.fetchUpdateAsync() - await Updates.reloadAsync() - return "updated" + try { + const check = await Updates.checkForUpdateAsync() + if (!check.isAvailable) return "up-to-date" + await Updates.fetchUpdateAsync() + await Updates.reloadAsync() + return "updated" + } catch { + return "up-to-date" + } } diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts index 5ae1564..0a1e107 100644 --- a/apps/mobile/lib/utils.ts +++ b/apps/mobile/lib/utils.ts @@ -6,16 +6,44 @@ export function cn(...inputs: ClassValue[]) { } const authCache = new Map() +const AUTH_CACHE_LIMIT = 10 + +const B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +function toBase64Utf8(input: string): string { + const bytes = new TextEncoder().encode(input) + let out = "" + for (let i = 0; i < bytes.length; i += 3) { + const b = ((bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)) >>> 0 + out += B64_CHARS[(b >>> 18) & 63] + B64_CHARS[(b >>> 12) & 63] + B64_CHARS[(b >>> 6) & 63] + B64_CHARS[b & 63] + } + const pad = bytes.length % 3 + if (pad === 1) return out.slice(0, -2) + "==" + if (pad === 2) return out.slice(0, -1) + "=" + return out +} export function getAuthHeader(token: string): string { let cached = authCache.get(token) if (!cached) { - cached = `Basic ${btoa(`opencode:${token}`)}` + cached = `Basic ${toBase64Utf8(`opencode:${token}`)}` + if (authCache.size >= AUTH_CACHE_LIMIT) { + const oldest = authCache.keys().next().value + if (oldest !== undefined) authCache.delete(oldest) + } authCache.set(token, cached) } return cached } +export function clearAuthCache(token?: string): void { + if (token) { + authCache.delete(token) + return + } + authCache.clear() +} + export function formatDirectory(path: string | undefined | null): string { if (!path) return "" const cleaned = path.replace(/\/+$/, '') diff --git a/apps/mobile/lib/widget-data.ts b/apps/mobile/lib/widget-data.ts index 328bb48..74e341c 100644 --- a/apps/mobile/lib/widget-data.ts +++ b/apps/mobile/lib/widget-data.ts @@ -1,4 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage" +import * as SecureStore from "expo-secure-store" import { DAILY_HISTORY_LIMIT, type DailyBucket, todayKey } from "@/store/opencode-stats.store" const CONNECTIONS_KEY = "crosscode-connections" @@ -44,6 +45,19 @@ interface ProjectStats { async function readPersisted(key: string): Promise { try { + // Connection/auth stores persist via secureStorage (SecureStore first, + // AsyncStorage fallback). Check both so widgets don't go stale. + if (key === CONNECTIONS_KEY) { + try { + if (await SecureStore.isAvailableAsync().catch(() => false)) { + const secured = await SecureStore.getItemAsync(key) + if (secured) { + const parsed = JSON.parse(secured) as Persisted + if (parsed?.state !== undefined) return parsed.state + } + } + } catch {} + } const raw = await AsyncStorage.getItem(key) if (!raw) return undefined const parsed = JSON.parse(raw) as Persisted @@ -95,12 +109,14 @@ export interface WidgetData { } export async function buildWidgetData(): Promise { - const [connections, sessions, stats, questions] = await Promise.all([ + const [connections, sessionsState, statsState, questions] = await Promise.all([ readPersisted<{ connections: Connection[]; current: string | null }>(CONNECTIONS_KEY), - readPersisted(SESSIONS_KEY), - readPersisted>(STATS_KEY), + readPersisted<{ sessions: Session[] }>(SESSIONS_KEY), + readPersisted<{ projects: Record }>(STATS_KEY), readPersisted<{ questionsBySession: Record }>(QUESTIONS_KEY), ]) + const stats = statsState?.projects + const sessions = sessionsState?.sessions const currentId = connections?.current const current = diff --git a/apps/mobile/store/auth.store.ts b/apps/mobile/store/auth.store.ts index 529d7dd..f475dbf 100644 --- a/apps/mobile/store/auth.store.ts +++ b/apps/mobile/store/auth.store.ts @@ -1,6 +1,7 @@ import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" import { secureStorage } from "../lib/secure-storage" +import { clearAuthCache } from "../lib/utils" type User = { id: string @@ -28,7 +29,11 @@ export const useAuth = create()( serverUrl: null, isLoggedIn: false, login: (user, sessionToken, serverUrl) => set({ user, sessionToken, serverUrl: serverUrl ?? null, isLoggedIn: true }), - logout: () => set({ user: null, sessionToken: null, serverUrl: null, isLoggedIn: false }), + logout: () => { + clearAuthCache() + void secureStorage.removeItem("crosscode-auth") + set({ user: null, sessionToken: null, serverUrl: null, isLoggedIn: false }) + }, setUser: (user) => set({ user }), }), { diff --git a/apps/mobile/store/connection.store.ts b/apps/mobile/store/connection.store.ts index e30a3f0..dc3fa6f 100644 --- a/apps/mobile/store/connection.store.ts +++ b/apps/mobile/store/connection.store.ts @@ -1,11 +1,14 @@ import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" import { secureStorage } from "../lib/secure-storage" - -let nextID = 1 +import { clearAuthCache } from "../lib/utils" const uid = () => { - return `${Date.now()}-${nextID++}` + try { + const c = globalThis.crypto as Crypto | undefined + if (c && typeof c.randomUUID === "function") return c.randomUUID() + } catch {} + return `${Date.now()}-${Math.floor(Math.random() * 1e9).toString(36)}` } export type Connection = { @@ -32,24 +35,37 @@ type ConnectionStore = { export const useConnections = create()( persist( - (set) => ({ + (set, get) => ({ connections: [], current: null, activeConnections: [], addConnection: (con) => set((state) => { + const existing = state.connections.find((c) => c.url === con.url) + if (existing) { + if (existing.token !== con.token) clearAuthCache(existing.token) + return { + connections: state.connections.map((c) => + c.id === existing.id ? { ...c, ...con } : c + ), + current: existing.id, + } + } const newCon = { ...con, id: uid(), added: Date.now(), healthy: null } return { connections: [...state.connections, newCon], current: newCon.id, } }), - removeConnection: (id) => + removeConnection: (id) => { + const conn = get().connections.find((c) => c.id === id) + if (conn) clearAuthCache(conn.token) set((state) => ({ connections: state.connections.filter((c) => c.id !== id), current: state.current === id ? null : state.current, activeConnections: state.activeConnections.filter((a) => a !== id), - })), + })) + }, updateConnection: (id, updates) => set((state) => ({ connections: state.connections.map((c) => diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1894254..d10f55d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -29,6 +29,20 @@ export type DeviceLinkQrPayload = { export * from "./plans" +const MAX_QR_PAYLOAD_LEN = 8192 +const BASE64_RE = /^[A-Za-z0-9+/=]+$/ +const TOKEN_RE = /^[A-Za-z0-9\-_.]{16,256}$/ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function assertSafeEncoded(encoded: string): void { + if (typeof encoded !== "string" || encoded.length === 0 || encoded.length > MAX_QR_PAYLOAD_LEN) { + throw new Error("Invalid QR payload") + } + if (!BASE64_RE.test(encoded)) { + throw new Error("Invalid QR payload") + } +} + function toBase64(str: string): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' let result = '' @@ -69,10 +83,15 @@ export function encodeQrPayload(payload: QrPayload): string { } export function decodeQrPayload(encoded: string): QrPayload { + assertSafeEncoded(encoded) const parsed = JSON.parse(fromBase64(encoded)) if (typeof parsed.url !== 'string' || parsed.v !== 1) { throw new Error('Invalid QR payload') } + if (typeof parsed.token !== 'string' || !TOKEN_RE.test(parsed.token)) { + throw new Error('Invalid QR payload') + } + assertSafeConnectionUrl(parsed.url) return { url: parsed.url, token: parsed.token, v: parsed.v } } @@ -88,10 +107,23 @@ export function encodeLoginQrPayload(payload: LoginQrPayload): string { } export function decodeLoginQrPayload(encoded: string): LoginQrPayload { + assertSafeEncoded(encoded) const parsed = JSON.parse(fromBase64(encoded)) if (parsed.type !== "login" || typeof parsed.email !== "string" || parsed.v !== 1) { throw new Error('Invalid login QR payload') } + if (!EMAIL_RE.test(parsed.email)) { + throw new Error('Invalid login QR payload') + } + if (typeof parsed.name !== "string" || parsed.name.length === 0 || parsed.name.length > 128) { + throw new Error('Invalid login QR payload') + } + if (typeof parsed.sessionToken !== "string" || !TOKEN_RE.test(parsed.sessionToken)) { + throw new Error('Invalid login QR payload') + } + if (typeof parsed.tier !== "string" || parsed.tier.length === 0 || parsed.tier.length > 32) { + throw new Error('Invalid login QR payload') + } return { type: parsed.type, email: parsed.email, @@ -120,18 +152,57 @@ export function encodeDeviceLinkQrPayload(payload: Omit