diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ca4c3ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# Security Policy + +## Reporting a vulnerability + +AppLess is a demo, but it handles API keys and renders model-generated UI, so +vulnerability reports are taken seriously. Please do **not** open a public +issue for a security problem. Report it privately through GitHub's "Report a +vulnerability" flow on the repository (Security tab). Include a repro, the +affected commit, and whether keys or user data are exposed. We aim to +acknowledge reports within 7 days. + +## Scope notes + +- BYOK keys are stored on-device (SecureStore on iOS/Android; `localStorage` + on web, which is readable by any XSS - web is a dev surface). +- `EXPO_PUBLIC_*` env keys are inlined into the JS bundle at build time. + Never ship a production build with them set. + +## Hardening notes + +Everything the model emits is treated as untrusted output. These runtime +guards enforce what the system prompt can only request: + +- **Outbound links** - only `http:`/`https:` URLs reach `Linking`; every other + scheme (`javascript:`, `intent:`, `tel:`, `sms:`, `file:`, app schemes) is + blocked with a toast. See `src/genos/safety/urlPolicy.ts`, wired into + `GenOS.handleAction`. +- **Image hosts** - a model-supplied image URL resolves only when it is an + https URL on `images.unsplash.com`, `loremflickr.com`, or + `upload.wikimedia.org`; anything else falls back to the keyless LoremFlickr + semantic path. This closes the "image URL with encoded conversation + context" exfiltration channel. See `src/genos/tools/images.ts`. +- **`genos://` deep links** - `genos://open` targets must be catalog apps or + the safe summon-id shape; the injected request is capped at 300 chars, and + `genos://toast` text at 120 chars. See `src/genos/safety/genosLink.ts`, + applied at the `parseGenosUrl` callsite and again in `store.openDeepLink`. +- **Form redaction** - credential-shaped field values (`password`, `pin`, + `cvv`, `otp`, `token`, ...) are replaced with `"[redacted]"` before + submitted form state is sent to the model provider or replayed as + conversation context. See `src/genos/safety/redaction.ts`, applied in + `store.resolveAction`. +- **Key entry** - the API-key field is masked (`secureTextEntry`). See + `src/genos/shell/KeyGate.tsx`. + +## Honest limits + +- Redaction keys off field **names**, not field type metadata (the type does + not reach the store): a password field named `favoriteWord` is not + redacted. Blocking credential screens outright is a contract-level decision + left to upstream. +- Web-search tool results are still fed back to the model unmarked as + untrusted data; injection-resistant tool-result handling is roadmap work, + not shipped here. +- Provider error bodies are still surfaced raw in the UI; friendly error + mapping is not part of this pass. +- Telemetry ships with the upstream PostHog key by default (see README); + unchanged here. +- Prompt rules ("https links only", "use the image service", the fixed app + list) remain advisory - the guards above are the enforcement. diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts new file mode 100644 index 0000000..c6023cf --- /dev/null +++ b/__tests__/security.test.ts @@ -0,0 +1,184 @@ +/** + * Security regression tests for the trust layer: outbound URL policy, + * genos:// deep-link validation and caps, credential redaction before form + * values reach the model provider, and the image-host policy. + */ + +jest.mock("expo-secure-store", () => ({ + getItemAsync: async () => null, + setItemAsync: async () => {}, + deleteItemAsync: async () => {}, +})); +jest.mock("expo/fetch", () => ({ fetch: jest.fn() })); + +// Capture stream launches instead of hitting Cerebras (store redaction test). +jest.mock("../src/genos/stream", () => ({ + NEEDS_LIVE_DATA: "needs live data", + streamScreen: jest.fn(), +})); + +import { Linking } from "react-native"; +import { + GENOS_REQUEST_MAX, + GENOS_TOAST_MAX, + capToastText, + isAllowedAppId, + parseOpenLink, +} from "../src/genos/safety/genosLink"; +import { REDACTED, isSensitiveField, redactFormState } from "../src/genos/safety/redaction"; +import { isAllowedExternalUrl, openExternalUrl, urlScheme } from "../src/genos/safety/urlPolicy"; +import { openApp, resolveAction, screenStore } from "../src/genos/store"; +import { + imageUrlHost, + isAllowedImageUrl, + resolveExternalImageUrl, +} from "../src/genos/tools/images"; + +const openURLSpy = jest.spyOn(Linking, "openURL").mockResolvedValue(true); + +beforeEach(() => { + openURLSpy.mockClear(); +}); + +describe("urlPolicy: outbound scheme allowlist", () => { + it("extracts the scheme case-insensitively, null when absent", () => { + expect(urlScheme("https://example.com/x")).toBe("https:"); + expect(urlScheme("HTTP://example.com")).toBe("http:"); + expect(urlScheme("javascript:alert(1)")).toBe("javascript:"); + expect(urlScheme(" tel:+15551234")).toBe("tel:"); + expect(urlScheme("example.com/path")).toBeNull(); + expect(urlScheme("//example.com")).toBeNull(); + }); + + it("opens http and https links", () => { + const onBlocked = jest.fn(); + openExternalUrl("https://example.com/page", onBlocked); + openExternalUrl("http://example.com", onBlocked); + openExternalUrl(" HTTPS://EXAMPLE.COM/Caps ", onBlocked); + expect(openURLSpy).toHaveBeenCalledTimes(3); + expect(openURLSpy).toHaveBeenNthCalledWith(3, "HTTPS://EXAMPLE.COM/Caps"); + expect(onBlocked).not.toHaveBeenCalled(); + expect(isAllowedExternalUrl("https://example.com")).toBe(true); + }); + + it("blocks every other scheme instead of calling Linking", () => { + const blocked = [ + "javascript:alert(document.cookie)", + "intent://scan/#Intent;scheme=zxing;package=com.evil;end", + "file:///etc/passwd", + "sms:+15551234?body=send%20me%20money", + "tel:+15551234", + "mailto:a@b.c?subject=x&body=y", + "genos://toast?text=hi", + "market://details?id=com.evil", + "example.com/no-scheme", + "", + ]; + for (const url of blocked) { + const onBlocked = jest.fn(); + openExternalUrl(url, onBlocked); + expect(onBlocked).toHaveBeenCalledTimes(1); + expect(isAllowedExternalUrl(url)).toBe(false); + } + expect(openURLSpy).not.toHaveBeenCalled(); + }); +}); + +describe("genosLink: deep-link validation and caps", () => { + it("accepts catalog ids and safe summon-slug shapes only", () => { + expect(isAllowedAppId("food")).toBe(true); + expect(isAllowedAppId("Music")).toBe(true); + expect(isAllowedAppId("summon-pizza-tracker")).toBe(true); + expect(isAllowedAppId("a".repeat(32))).toBe(true); + expect(isAllowedAppId("a".repeat(33))).toBe(false); + expect(isAllowedAppId("evil app!")).toBe(false); + expect(isAllowedAppId("../settings")).toBe(false); + expect(isAllowedAppId("")).toBe(false); + }); + + it("validates open params and caps the request at 300 chars", () => { + expect(parseOpenLink({ app: "Food", request: "show lunch spots" })).toEqual({ + appId: "food", + request: "show lunch spots", + }); + const long = parseOpenLink({ app: "food", request: "x".repeat(400) }); + expect(long?.request).toHaveLength(GENOS_REQUEST_MAX); + expect(GENOS_REQUEST_MAX).toBe(300); + expect(parseOpenLink({ app: "evil app!", request: "hi" })).toBeNull(); + expect(parseOpenLink({ app: "food" })).toBeNull(); + expect(parseOpenLink({ request: "hi" })).toBeNull(); + expect(parseOpenLink({})).toBeNull(); + }); + + it("caps toast text at 120 chars", () => { + expect(capToastText("Done ✓")).toBe("Done ✓"); + expect(capToastText("y".repeat(200))).toHaveLength(GENOS_TOAST_MAX); + expect(GENOS_TOAST_MAX).toBe(120); + }); +}); + +describe("redaction: credential-shaped form fields", () => { + it("flags credential words without hitting lookalikes", () => { + for (const name of ["password", "Password", "cardCvv", "user_pin", "otp", "passphrase"]) { + expect(isSensitiveField(name)).toBe(true); + } + for (const name of ["shopping", "email", "username", "spinner", "city"]) { + expect(isSensitiveField(name)).toBe(false); + } + }); + + it("replaces sensitive values, keeps the rest", () => { + const out = redactFormState({ password: "hunter2", cardCvv: "123", city: "Goa" }); + expect(out).toEqual({ password: REDACTED, cardCvv: REDACTED, city: "Goa" }); + expect(redactFormState({})).toEqual({}); + }); + + it("strips credentials from the request sent to the provider", () => { + const app = { + id: "food", + name: "Food", + emoji: "🍜", + tile: ["#000", "#111"] as [string, string], + request: "Open food", + }; + const parentId = openApp(app); + screenStore.patch(parentId, { status: "done" }); + const child = resolveAction(parentId, "Checkout", { + password: "hunter2", + cardCvv: "123", + city: "Goa", + }); + const request = screenStore.get(child)?.request ?? ""; + expect(request).not.toContain("hunter2"); + expect(request).not.toContain("123"); + expect(request).toContain(REDACTED); + expect(request).toContain("Goa"); + }); +}); + +describe("image-host policy", () => { + it("parses https hosts only", () => { + expect(imageUrlHost("https://images.unsplash.com/photo-1?w=80")).toBe("images.unsplash.com"); + expect(imageUrlHost("https://ATTACKER.example.evil/x.png")).toBe("attacker.example.evil"); + expect(imageUrlHost("http://images.unsplash.com/x")).toBeNull(); + expect(imageUrlHost("ftp://images.unsplash.com/x")).toBeNull(); + expect(imageUrlHost("/api/img?q=cats")).toBeNull(); + }); + + it("allows only the three image hosts over https", () => { + expect(isAllowedImageUrl("https://images.unsplash.com/photo-1")).toBe(true); + expect(isAllowedImageUrl("https://loremflickr.com/800/500/cats?lock=1")).toBe(true); + expect(isAllowedImageUrl("https://upload.wikimedia.org/w/a/b.png")).toBe(true); + // The exfil shape: arbitrary host, context encoded in the query. + expect(isAllowedImageUrl("https://attacker.example/c.png?d=secret")).toBe(false); + expect(isAllowedImageUrl("http://images.unsplash.com/photo-1")).toBe(false); + }); + + it("falls back to the LoremFlickr semantic path for blocked URLs", () => { + const good = "https://upload.wikimedia.org/w/a/b.png"; + expect(resolveExternalImageUrl(good)).toBe(good); + expect(resolveExternalImageUrl("https://attacker.example/c.png?d=secret")).toBe( + "https://loremflickr.com/800/500/abstract%2Cgradient?lock=1", + ); + }); +}); diff --git a/src/genos/GenOS.tsx b/src/genos/GenOS.tsx index 6e6b29e..11c8ed8 100644 --- a/src/genos/GenOS.tsx +++ b/src/genos/GenOS.tsx @@ -12,7 +12,6 @@ import { BackHandler, Easing, KeyboardAvoidingView, - Linking, Platform, Pressable, ScrollView, @@ -25,6 +24,8 @@ import { cerebrasKey } from "../config"; import type { AppDef } from "./apps"; import { APPS, DEFAULT_TILE, summonApp } from "./apps"; import { genosLibrary } from "./library"; +import { capToastText, parseOpenLink } from "./safety/genosLink"; +import { openExternalUrl } from "./safety/urlPolicy"; import { HomeScreen } from "./shell/HomeScreen"; import { KeyGate } from "./shell/KeyGate"; import { Switcher, type RunningApp } from "./shell/Switcher"; @@ -422,16 +423,17 @@ export default function GenOS() { const parsed = parseGenosUrl(url); if (parsed) { const { cmd, params } = parsed; - if (cmd === "toast") showToast(params.text || "Done ✓"); + if (cmd === "toast") showToast(capToastText(params.text || "Done ✓")); else if (cmd === "open") { - if (params.app && params.request) deepLink(params.app, params.request); + const link = parseOpenLink(params); + if (link) deepLink(link.appId, link.request); } else if (cmd === "back") goBack(); else if (cmd === "home") goHome(); } return; } if (typeof url === "string" && url) { - Linking.openURL(url).catch(() => {}); + openExternalUrl(url, () => showToast("That link type isn't supported")); return; } const message = ev.humanFriendlyMessage?.trim(); diff --git a/src/genos/safety/genosLink.ts b/src/genos/safety/genosLink.ts new file mode 100644 index 0000000..674e977 --- /dev/null +++ b/src/genos/safety/genosLink.ts @@ -0,0 +1,42 @@ +/** + * genos:// deep-link hardening. Deep links come from model output, so the + * open target and payloads are validated before the shell acts on them: the + * target must be a known catalog app or the safe summon-id shape, the request + * is length-capped (it is injected verbatim as the user message of a new + * generation - an unbounded one is a cross-app prompt-injection channel), + * and toast text is capped so a poisoned screen can't spoof long system- + * looking messages. + */ +import { APPS } from "../apps"; + +/** Longest request payload a genos://open link may carry. */ +export const GENOS_REQUEST_MAX = 300; +/** Longest text a genos://toast link may show. */ +export const GENOS_TOAST_MAX = 120; + +/** Safe app-id shape: covers every catalog id and summon-* slugs. */ +const APP_ID_RE = /^[a-z0-9-]{1,32}$/; + +/** App ids a genos://open link may target (catalog ids + safe slug shape). */ +export function isAllowedAppId(raw: string): boolean { + const id = raw.trim().toLowerCase(); + return APP_ID_RE.test(id) || APPS.some((a) => a.id === id); +} + +export interface OpenLinkParams { + appId: string; + request: string; +} + +/** Validated genos://open params; null when anything is missing or unsafe. */ +export function parseOpenLink(params: Record): OpenLinkParams | null { + const app = params.app?.trim(); + const request = params.request?.trim(); + if (!app || !request || !isAllowedAppId(app)) return null; + return { appId: app.toLowerCase(), request: request.slice(0, GENOS_REQUEST_MAX) }; +} + +/** Toast text from a genos://toast link, length-capped. */ +export function capToastText(raw: string): string { + return raw.slice(0, GENOS_TOAST_MAX); +} diff --git a/src/genos/safety/redaction.ts b/src/genos/safety/redaction.ts new file mode 100644 index 0000000..5fdb44c --- /dev/null +++ b/src/genos/safety/redaction.ts @@ -0,0 +1,50 @@ +/** + * Form-value redaction. Submitted formState is serialized into the next model + * request and replays as conversation context (it leaves the device, to the + * configured provider, possibly several times), so credential-shaped values + * must be stripped first. Field type metadata does not reach resolveAction, + * so redaction keys off the field NAME the model chose - password-type fields + * follow the obvious naming convention, and common credential words are + * covered too. Whole-word matching keeps "shopping" and "spinner" safe. + */ + +/** Value substituted for a sensitive field. */ +export const REDACTED = "[redacted]"; + +/** Words that mark a field name as credential-shaped. */ +const SENSITIVE_WORDS = new Set([ + "password", + "passwd", + "passphrase", + "passcode", + "pin", + "secret", + "cvv", + "cvc", + "ssn", + "otp", + "token", +]); + +/** Field names arrive camelCase / snake_case / kebab - split into words. */ +function fieldWords(name: string): string[] { + return name + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +/** True when a field name reads as credential-shaped ("cardCvv", "user_pin"). */ +export function isSensitiveField(name: string): boolean { + return fieldWords(name).some((w) => SENSITIVE_WORDS.has(w)); +} + +/** Copy formState with sensitive values replaced by REDACTED. */ +export function redactFormState(formState: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(formState)) { + out[key] = isSensitiveField(key) ? REDACTED : value; + } + return out; +} diff --git a/src/genos/safety/urlPolicy.ts b/src/genos/safety/urlPolicy.ts new file mode 100644 index 0000000..0415a89 --- /dev/null +++ b/src/genos/safety/urlPolicy.ts @@ -0,0 +1,36 @@ +/** + * Outbound-link policy. A generated screen can attach any URL string to a tap + * (@OpenUrl) and model output is untrusted, so only http(s) links ever reach + * Linking. Everything else (javascript:, intent:, tel:, sms:, file:, third- + * party app schemes, universal links) is blocked and reported via onBlocked. + * The scheme is parsed by hand - Hermes' URL support for odd schemes varies. + */ +import { Linking } from "react-native"; + +/** Schemes a model-supplied link may use. */ +const ALLOWED_SCHEMES = new Set(["https:", "http:"]); + +/** Scheme prefix of a URL, lowercased with trailing colon; null if none. */ +export function urlScheme(raw: string): string | null { + const m = raw.trim().match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); + return m ? `${m[1].toLowerCase()}:` : null; +} + +/** True when a raw URL is openable under the scheme allowlist. */ +export function isAllowedExternalUrl(raw: string): boolean { + const scheme = urlScheme(raw); + return scheme !== null && ALLOWED_SCHEMES.has(scheme); +} + +/** + * Open an external link when policy allows it; blocked links call onBlocked + * instead (the shell shows a toast). Open failures are swallowed, matching + * the previous behavior - a dead link must not crash the shell. + */ +export function openExternalUrl(raw: string, onBlocked: () => void) { + if (!isAllowedExternalUrl(raw)) { + onBlocked(); + return; + } + Linking.openURL(raw.trim()).catch(() => {}); +} diff --git a/src/genos/shell/KeyGate.tsx b/src/genos/shell/KeyGate.tsx index 4a07c34..0f920cc 100644 --- a/src/genos/shell/KeyGate.tsx +++ b/src/genos/shell/KeyGate.tsx @@ -60,6 +60,7 @@ export function KeyGate({ status }: { status: KeyStatus }) { placeholderTextColor={t.ink3} autoCapitalize="none" autoCorrect={false} + secureTextEntry onSubmitEditing={save} style={{ width: "100%", diff --git a/src/genos/store.ts b/src/genos/store.ts index 5d943e1..43c5019 100644 --- a/src/genos/store.ts +++ b/src/genos/store.ts @@ -1,5 +1,7 @@ import type { AppDef } from "./apps"; import { APPS } from "./apps"; +import { GENOS_REQUEST_MAX } from "./safety/genosLink"; +import { redactFormState } from "./safety/redaction"; import type { ChatMessage } from "./stream"; import { streamScreen } from "./stream"; @@ -311,7 +313,10 @@ const deepLinkIndex = new Map(); /** Open a screen in another app via a genos://open deep link. */ export function openDeepLink(appId: string, request: string): string { - const key = `${appId.toLowerCase()} ${request}`; + // Deep links are model output: cap the injected request here too, not only + // at the genos:// callsite, so any future caller gets the same bound. + const capped = request.slice(0, GENOS_REQUEST_MAX); + const key = `${appId.toLowerCase()} ${capped}`; const existing = deepLinkIndex.get(key); if (existing) { const screen = screenStore.get(existing); @@ -325,7 +330,7 @@ export function openDeepLink(appId: string, request: string): string { const id = launchScreen({ appId: app?.id ?? appId.toLowerCase(), appName: app?.name ?? appId.charAt(0).toUpperCase() + appId.slice(1), - request, + request: capped, speculative: false, }); deepLinkIndex.set(key, id); @@ -365,7 +370,9 @@ export function resolveAction( } const request = hasFormValues - ? `${message}\n\nSubmitted form values: ${JSON.stringify(formState)}` + ? // Credential-shaped field values are redacted before they leave the + // device - the request also replays as context on later screens. + `${message}\n\nSubmitted form values: ${JSON.stringify(redactFormState(formState))}` : message; const id = launchScreen({ appId: parent?.appId ?? "unknown", diff --git a/src/genos/tools/images.ts b/src/genos/tools/images.ts index 39edc3a..6c80e57 100644 --- a/src/genos/tools/images.ts +++ b/src/genos/tools/images.ts @@ -48,6 +48,38 @@ export function loremflickrUrl({ q, seed, w, h }: ImgQuery): string { return `https://loremflickr.com/${w}/${h}/${keywords}?lock=${seed}`; } +/** + * A non-semantic src is a URL the model made up: an outbound GET it fully + * controls, and a channel to smuggle conversation context to any host (the + * prompt-injection exfil path). Only https URLs on allowlisted image hosts + * resolve; anything else falls back to the keyless LoremFlickr path below. + */ +export const ALLOWED_IMAGE_HOSTS = new Set([ + "images.unsplash.com", + "loremflickr.com", + "upload.wikimedia.org", +]); + +/** Host of an absolute https URL, lowercased; null for anything else. */ +export function imageUrlHost(src: string): string | null { + const m = src.trim().match(/^https:\/\/([a-z0-9.-]+)(?::\d+)?(?:[/?#]|$)/i); + return m ? (m[1]?.toLowerCase() ?? null) : null; +} + +/** True when a model-supplied URL points at an allowlisted https image host. */ +export function isAllowedImageUrl(src: string): boolean { + const host = imageUrlHost(src); + return host !== null && ALLOWED_IMAGE_HOSTS.has(host); +} + +/** Blocked URLs resolve to this generic semantic image instead. */ +const FALLBACK_QUERY: ImgQuery = { q: "abstract gradient", seed: 1, w: 800, h: 500 }; + +/** What a non-semantic src may load: the URL itself, or the safe fallback. */ +export function resolveExternalImageUrl(src: string): string { + return isAllowedImageUrl(src) ? src : loremflickrUrl(FALLBACK_QUERY); +} + /** query → Unsplash raw URLs; empty array = search failed, use LoremFlickr. */ const unsplashCache = new Map(); const unsplashPending = new Map>(); @@ -79,7 +111,8 @@ function ensureUnsplash(q: string): Promise { } /** - * Resolve a generated src to a loadable URL. Non-semantic srcs pass through. + * Resolve a generated src to a loadable URL. Non-semantic srcs go through the + * image-host policy (allowlisted hosts only, else the LoremFlickr fallback). * With an Unsplash key, returns undefined (placeholder) while the search is * in flight so the image doesn't double-load. */ @@ -107,7 +140,7 @@ export function useSemanticImage(src?: string): string | undefined { }, [q]); if (!src) return undefined; - if (!parsed) return src; + if (!parsed) return resolveExternalImageUrl(src); if (!q) return loremflickrUrl(parsed); const candidates = unsplashCache.get(parsed.q); if (candidates === undefined) return undefined; // still searching diff --git a/src/genos/ui/shared/media.tsx b/src/genos/ui/shared/media.tsx index c914c47..24b383d 100644 --- a/src/genos/ui/shared/media.tsx +++ b/src/genos/ui/shared/media.tsx @@ -2,7 +2,9 @@ * Semantic image element shared by all design systems: resolves /api/img * queries via useSemanticImage, fades in on load, and shows a themed * placeholder while unresolved. Follows the createMapRenderer pattern - - * design systems supply only their placeholder color. + * design systems supply only their placeholder color. Raw model-supplied + * URLs never reach directly: useSemanticImage filters them through + * the image-host policy in tools/images.ts first. */ import React, { useState } from "react"; import { Image, View } from "react-native";