一块 React Native screen 活在 device 上,背后是任何人拿到 IPA 或 APK 都能读的 JavaScript bundle。一份 Next.js document 活在 browser origin 里。两边的 UI 都不受信任。两边仍然打同一套 Hono / Better Auth API。这篇 note 讲 device。
Category map 见 Web Security and the OWASP Top 10。Origin host 见 Next.js 里的 Security。这是对 React Native / Expo 的防御性阅读:failure modes 与 attacker goals,不是 exploits。
1. The Host
一次 React Native request 从 native modules 离开,然后遇见 Next.js document 用的同一台 server:
React Native:
JS thread → Native modules ─┬→ OS keychain
└→ TLS
OS keychain → Hono API → Authn then authz| Next.js | React Native | |
|---|---|---|
| Isolation unit | Browser origin | App ID / keychain access group |
| Session | HttpOnly; Secure; SameSite cookie | SecureStore / Keychain / Keystore —— 从不 AsyncStorage |
| XSS surface | DOM、dangerouslySetInnerHTML、next/script | WebView 加上你暴露的任何 JS bridge |
| Secrets | server-only modules;NEXT_PUBLIC_* 是 public | Binary 里没有任何东西是 secret |
| Deep entry | searchParams、open redirects、router.push | Custom URL schemes、universal links、push payloads |
- 两边 host 共享同一句谎言:UI 不是 authorization。把
isAdmin存进 AsyncStorage,并不决定某一行能不能被读。 - 深入理解 React Native 覆盖 renderer;这篇 note 是 extractable bundle、keychain,以及你 embed 的任何 WebView 周围的 threat model。
2. The Bundle Is Not a Secret
React Native 的 threat model 从 Next.js 的 server graph 结束的地方开始。没有 'use client' fence。Metro 的 output ship 在 device 上。
- 任何人能安装这个 app,就能 inspect JavaScript。Bundle 里的 API keys、「隐藏」的 feature flags,以及 hardcoded HMAC secrets,是多了几步的 public constants。
- Attacker goal 是 从来都不该离开 build laptop 的 credentials。
- Defense 与
NEXT_PUBLIC_*同一句,应用到整个 binary:app 持有 public identifiers 与 user-bound tokens,不是 authority。Hono API 仍然 authenticate 每一次 call。 - Binary 里可以有的:带 URL restrictions 的 Mapbox public token、Firebase client config、public native client 的 OAuth client id。不可以有的:database URL、mint sessions 的 HMAC、「debug admin」compile-time flag、Stripe secret key。
- Expo 在
app.json里的extra仍然在 bundle 里。Build time bake 进 client 的 EAS secrets 又是NEXT_PUBLIC_*。Hermes bytecode 减慢随便读;它不是 encryption。 - Mobile 上的 identity 是 内存里的 short-lived access token 加上 OS secret store 里的 refresh token,由 web app 用的同一套 Better Auth(或 OAuth)server 签发。
Failure: session material 放在 AsyncStorage、Redux Persist,或未加密的 MMKV。那些是带 native 口音的 localStorage。Jailbreak / root detection 不是 boundary。
3. SecureStore, Keychain, and Keystore
iOS Keychain 与 Android Keystore 是 web 上 cookies 对应的 isolation unit。expo-secure-store 是 JS 请 OS 持有一份 blob 的方式,app 其余部分不该随手 dump 到 disk。
await SecureStore.setItemAsync("auth.refresh", token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
})- 当产品不需要 cross-device restore 时,
WHEN_UNLOCKED_THIS_DEVICE_ONLY把 refresh token 留在 iCloud Keychain backups 之外。那是 product trade-off,不是万能规则。 - Session 可以 refresh 时,access tokens 留在内存;process death 然后只花一次 refresh,而不是一份躺在 world-readable file 里的 stolen long-lived bearer。
- Screenshot 与 recents protection(
FLAG_SECURE、iOS screen-capture notifications)是 banking-shaped screens 的 policy。它不是 cryptography。
Failure: 把 SecureStore 当成魔法。被 compromise 的 device、恶意键盘,或你 opt into 的 backup,仍然可以暴露它。Server 仍然 rotate、expire 与 revoke。
4. TLS and Pinning
在 web 上,browser 与 Let's Encrypt 替你做了大部分。在 mobile 上,OS 仍然验证 public PKI:iOS 上的 App Transport Security,Android 上的 Network Security Config。
- Cleartext HTTP 默认关,除非你明确打洞。Production API hosts 不拿 cleartext exceptions。
- Certificate pinning 是高风险 apps 的 defense in depth:client 额外要求
api.example.com的已知 key 或 SPKI。它提高 rogue CA 或公司 TLS-intercept box 的成本。 - Pinning 也意味着 pin 过期之前你需要 rotation story,否则你 ship 一块砖。只在 threat model 包含 hostile networks、且团队能用 app update 或 backup pin 去 rotate 时才 pin。
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>Failure: 把 transport security 当成 authorization。ATS / NSC 开着,若 OS 允许则不信任 user-installed CAs 打 API,pinning 只带 rotation——然后 API 仍然要求真正的 user session。
5. Deep Links
Custom URL scheme(myapp://)不是 origin。Device 上的其他 apps 常常能注册同一个 scheme。Verified universal link(iOS)或 App Link(Android)与你控制的 HTTPS domain 关联。
- Attacker goal 是 用选定的 path 与选定的 query params 打开 app:OAuth redirect、password-reset token、设置
organizationId的 campaign URL。 - 任何带着 token 或选择 account 的东西,优先 associated domains 而不是 raw schemes。
- 把每一条 incoming URL 当成 untrusted input。Parse 它。Allowlist paths。不要执行到达的任意 query string。
- Mobile 上的 OAuth 走 system browser(或 in-app browser tab)加上 PKCE,不是 app 能 script 的 embedded WebView。App 从不看见 user 的 password;它看见 OS 交到 claimed redirect 的 authorization code。
- FCM(以及其他 push)payloads 是同一类。打开 membership offer 的 campaign deep link 是 marketing。Client 解读成「把这张 invoice 标成 paid」的 push 是 unsigned RPC。
import * as Linking from "expo-linking"
const CAMPAIGN_PATHS = new Set(["/offers", "/card", "/inbox"])
export function pathFromDeepLink(url: string) {
const parsed = Linking.parse(url)
const path = parsed.path ? `/${parsed.path}` : "/"
if (!CAMPAIGN_PATHS.has(path)) {
return "/home"
}
return path
}那条 URL 上的 query params 仍然需要与 Next.js searchParams 同样的 Zod 处理。没有 session,它们不成 fetch bodies。
6. WebView
WebView 是 app 里的 browser origin。它加载的 HTML 可以在那个 origin 跑 script。如果你还暴露一条进 native modules 的 JavaScript bridge——camera、file system、session store——你就给了那页 privileged RPC。
- Attacker goal 是 在 WebView 里用 app 的 privileges 跑 script:一篇加载的 help article、你以为封死的 payment iframe、
file://page、redirect 到你不控制的 content。 - 不是你 UI 的 pages,优先 in-app browser tab(Safari View Controller / Chrome Custom Tabs,
expo-web-browser)。它们拿到真正 browser 的 origin isolation 与 system cookie jar,不是你的 bridge。 - 如果必须 WebView 自己的 content,把 navigation 锁到 allowlisted https origin,关掉不需要的 file access,不要注入能读 SecureStore 或打 authenticated API calls 的 bridge。
const ALLOWED_HOST = "help.example.com"
function onShouldStartLoadWithRequest(request: { url: string }) {
try {
const url = new URL(request.url)
return url.protocol === "https:" && url.hostname === ALLOWED_HOST
} catch {
return false
}
}Failure: injectedJavaScript 是 native 上的 dangerouslySetInnerHTML。User content 不属于那里。用 React Native views 渲染的 membership card 不需要 WebView。
7. Native Modules and Push
Third-party native code 是带第二套 compiler 的 supply chain。一个同时还 ship Android Gradle plugin 或 CocoaPod 的 npm package,能做 OS 授予这个 app 的任何事。
- OWASP supply-chain category 适用;额外事实是 JS review 看不见 native 那一半。
- Versions pin 住,
autolinkingoutput 被 review,新 native module 是一次 permissions review:这加了哪些 entitlements? - Device push token 向 APNs 或 FCM 标识一台 device。它不标识一个 user。把 token bind 到 account 发生在 server 上、verified session 之后。
- Login 时 register token(
POST /devices);logout 时 unregister。/devices仍然用 SecureStore 里的 session authenticate——token 是 destination,不是 credential。 - JS thread 可以被 pause;native work 继续。Logout 是 native operation,不只是 React state reset:取消 in-flight uploads,wipe SecureStore。
Failure: 因为一条 push 到达就授权 mutation,或接受 client 替别人 posted 的 token。
8. Defaults
- Bundle 是 public。Refresh tokens 活在 SecureStore / Keychain,不是 AsyncStorage。产品允许时,access tokens 留在内存。
- ATS / Network Security Config 开着;pinning 只带 rotation plan。
- Universal links 优于 raw schemes。OAuth 走 system browser 与 PKCE。Deep links 与 push payloads 是 untrusted input。
- 没有 privileged WebView bridge。Logout wipe secret store 与 push binding。
Keychain、session 与 authorization check 才是产品。Renderer 不是。Origin 上的同一句话见 Next.js 里的 Security。