From 849699e93fbfd42006e32d48658e53da49e06029 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Fri, 20 Feb 2026 00:58:04 +0800 Subject: [PATCH] fix: cache path check --- packages/frontend/apps/android/package.json | 1 + .../apps/android/src/plugins/nbstore/index.ts | 33 ++------- packages/frontend/apps/android/tsconfig.json | 1 + packages/frontend/apps/ios/package.json | 1 + .../apps/ios/src/plugins/nbstore/index.ts | 33 ++------- packages/frontend/apps/ios/tsconfig.json | 1 + .../frontend/apps/mobile-shared/package.json | 18 +++++ .../frontend/apps/mobile-shared/src/index.ts | 1 + .../apps/mobile-shared/src/nbstore/payload.ts | 58 +++++++++++++++ .../frontend/apps/mobile-shared/tsconfig.json | 10 +++ .../mobile-native/src/mobile_blob_cache.rs | 72 +++++++++++++++++-- packages/frontend/native/nbstore/src/pool.rs | 18 +++-- packages/frontend/native/package.json | 2 +- tools/utils/src/workspace.gen.ts | 8 +++ tsconfig.json | 1 + yarn.lock | 12 ++++ 16 files changed, 203 insertions(+), 67 deletions(-) create mode 100644 packages/frontend/apps/mobile-shared/package.json create mode 100644 packages/frontend/apps/mobile-shared/src/index.ts create mode 100644 packages/frontend/apps/mobile-shared/src/nbstore/payload.ts create mode 100644 packages/frontend/apps/mobile-shared/tsconfig.json diff --git a/packages/frontend/apps/android/package.json b/packages/frontend/apps/android/package.json index 82b06adea2..47a519f2c0 100644 --- a/packages/frontend/apps/android/package.json +++ b/packages/frontend/apps/android/package.json @@ -15,6 +15,7 @@ "@affine/core": "workspace:*", "@affine/env": "workspace:*", "@affine/i18n": "workspace:*", + "@affine/mobile-shared": "workspace:*", "@affine/nbstore": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", diff --git a/packages/frontend/apps/android/src/plugins/nbstore/index.ts b/packages/frontend/apps/android/src/plugins/nbstore/index.ts index bb3332855d..691e7c9d52 100644 --- a/packages/frontend/apps/android/src/plugins/nbstore/index.ts +++ b/packages/frontend/apps/android/src/plugins/nbstore/index.ts @@ -1,7 +1,9 @@ +import { uint8ArrayToBase64 } from '@affine/core/modules/workspace-engine'; import { - base64ToUint8Array, - uint8ArrayToBase64, -} from '@affine/core/modules/workspace-engine'; + decodePayload, + MOBILE_BLOB_FILE_PREFIX, + MOBILE_DOC_FILE_PREFIX, +} from '@affine/mobile-shared/nbstore/payload'; import { type BlobRecord, type CrawlResult, @@ -12,36 +14,13 @@ import { parseUniversalId, } from '@affine/nbstore'; import { type NativeDBApis } from '@affine/nbstore/sqlite'; -import { Capacitor, registerPlugin } from '@capacitor/core'; +import { registerPlugin } from '@capacitor/core'; import type { NbStorePlugin } from './definitions'; export * from './definitions'; export const NbStore = registerPlugin('NbStoreDocStorage'); -const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:'; -const MOBILE_DOC_FILE_PREFIX = '__AFFINE_DOC_FILE__:'; - -async function decodePayload( - data: string, - prefix: string -): Promise { - if (!data.startsWith(prefix)) { - return base64ToUint8Array(data); - } - - const filePath = data.slice(prefix.length); - const normalizedPath = filePath.startsWith('file://') - ? filePath - : `file://${filePath}`; - const response = await fetch(Capacitor.convertFileSrc(normalizedPath)); - if (!response.ok) { - throw new Error( - `Failed to read mobile payload file: ${filePath} (status ${response.status})` - ); - } - return new Uint8Array(await response.arrayBuffer()); -} export const NbStoreNativeDBApis: NativeDBApis = { connect: async function (id: string): Promise { diff --git a/packages/frontend/apps/android/tsconfig.json b/packages/frontend/apps/android/tsconfig.json index d78e2256cf..842190ba15 100644 --- a/packages/frontend/apps/android/tsconfig.json +++ b/packages/frontend/apps/android/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../../core" }, { "path": "../../../common/env" }, { "path": "../../i18n" }, + { "path": "../mobile-shared" }, { "path": "../../../common/nbstore" }, { "path": "../../track" }, { "path": "../../../../blocksuite/affine/all" }, diff --git a/packages/frontend/apps/ios/package.json b/packages/frontend/apps/ios/package.json index b29ed1e351..950574f98a 100644 --- a/packages/frontend/apps/ios/package.json +++ b/packages/frontend/apps/ios/package.json @@ -19,6 +19,7 @@ "@affine/env": "workspace:*", "@affine/graphql": "workspace:*", "@affine/i18n": "workspace:*", + "@affine/mobile-shared": "workspace:*", "@affine/nbstore": "workspace:*", "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", diff --git a/packages/frontend/apps/ios/src/plugins/nbstore/index.ts b/packages/frontend/apps/ios/src/plugins/nbstore/index.ts index bb3332855d..691e7c9d52 100644 --- a/packages/frontend/apps/ios/src/plugins/nbstore/index.ts +++ b/packages/frontend/apps/ios/src/plugins/nbstore/index.ts @@ -1,7 +1,9 @@ +import { uint8ArrayToBase64 } from '@affine/core/modules/workspace-engine'; import { - base64ToUint8Array, - uint8ArrayToBase64, -} from '@affine/core/modules/workspace-engine'; + decodePayload, + MOBILE_BLOB_FILE_PREFIX, + MOBILE_DOC_FILE_PREFIX, +} from '@affine/mobile-shared/nbstore/payload'; import { type BlobRecord, type CrawlResult, @@ -12,36 +14,13 @@ import { parseUniversalId, } from '@affine/nbstore'; import { type NativeDBApis } from '@affine/nbstore/sqlite'; -import { Capacitor, registerPlugin } from '@capacitor/core'; +import { registerPlugin } from '@capacitor/core'; import type { NbStorePlugin } from './definitions'; export * from './definitions'; export const NbStore = registerPlugin('NbStoreDocStorage'); -const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:'; -const MOBILE_DOC_FILE_PREFIX = '__AFFINE_DOC_FILE__:'; - -async function decodePayload( - data: string, - prefix: string -): Promise { - if (!data.startsWith(prefix)) { - return base64ToUint8Array(data); - } - - const filePath = data.slice(prefix.length); - const normalizedPath = filePath.startsWith('file://') - ? filePath - : `file://${filePath}`; - const response = await fetch(Capacitor.convertFileSrc(normalizedPath)); - if (!response.ok) { - throw new Error( - `Failed to read mobile payload file: ${filePath} (status ${response.status})` - ); - } - return new Uint8Array(await response.arrayBuffer()); -} export const NbStoreNativeDBApis: NativeDBApis = { connect: async function (id: string): Promise { diff --git a/packages/frontend/apps/ios/tsconfig.json b/packages/frontend/apps/ios/tsconfig.json index 68135c9217..40a5035526 100644 --- a/packages/frontend/apps/ios/tsconfig.json +++ b/packages/frontend/apps/ios/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../../../common/env" }, { "path": "../../../common/graphql" }, { "path": "../../i18n" }, + { "path": "../mobile-shared" }, { "path": "../../../common/nbstore" }, { "path": "../../track" }, { "path": "../../../../blocksuite/affine/all" }, diff --git a/packages/frontend/apps/mobile-shared/package.json b/packages/frontend/apps/mobile-shared/package.json new file mode 100644 index 0000000000..e887bfe2bd --- /dev/null +++ b/packages/frontend/apps/mobile-shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@affine/mobile-shared", + "version": "0.26.1", + "type": "module", + "private": true, + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./nbstore/payload": "./src/nbstore/payload.ts" + }, + "dependencies": { + "@affine/core": "workspace:*", + "@capacitor/core": "^7.0.0" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/packages/frontend/apps/mobile-shared/src/index.ts b/packages/frontend/apps/mobile-shared/src/index.ts new file mode 100644 index 0000000000..a203d83dd0 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/index.ts @@ -0,0 +1 @@ +export * from './nbstore/payload'; diff --git a/packages/frontend/apps/mobile-shared/src/nbstore/payload.ts b/packages/frontend/apps/mobile-shared/src/nbstore/payload.ts new file mode 100644 index 0000000000..c4788ebba7 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/src/nbstore/payload.ts @@ -0,0 +1,58 @@ +import { base64ToUint8Array } from '@affine/core/modules/workspace-engine'; +import { Capacitor } from '@capacitor/core'; + +export const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:'; +export const MOBILE_DOC_FILE_PREFIX = '__AFFINE_DOC_FILE__:'; +const MOBILE_PAYLOAD_CACHE_PATH_PATTERN = + /\/nbstore-blob-cache\/[0-9a-f]{16}\/[0-9a-f]{16}\.(blob|docbin)$/; + +function normalizeTokenFilePath(rawPath: string): string { + const trimmedPath = rawPath.trim(); + if (!trimmedPath) { + throw new Error('Invalid mobile payload token: empty file path'); + } + + return trimmedPath.startsWith('file://') + ? trimmedPath + : `file://${trimmedPath}`; +} + +function assertMobileCachePath(fileUrl: string): void { + let pathname: string; + try { + pathname = decodeURIComponent(new URL(fileUrl).pathname); + } catch { + throw new Error('Invalid mobile payload token: malformed file URL'); + } + + if ( + pathname.includes('/../') || + pathname.includes('/./') || + !MOBILE_PAYLOAD_CACHE_PATH_PATTERN.test(pathname) + ) { + throw new Error( + `Refusing to read mobile payload outside cache dir: ${fileUrl}` + ); + } +} + +export async function decodePayload( + data: string, + prefix: string +): Promise { + if (!data.startsWith(prefix)) { + return base64ToUint8Array(data); + } + + const normalizedPath = normalizeTokenFilePath(data.slice(prefix.length)); + assertMobileCachePath(normalizedPath); + + const response = await fetch(Capacitor.convertFileSrc(normalizedPath)); + if (!response.ok) { + throw new Error( + `Failed to read mobile payload file: ${normalizedPath} (status ${response.status})` + ); + } + + return new Uint8Array(await response.arrayBuffer()); +} diff --git a/packages/frontend/apps/mobile-shared/tsconfig.json b/packages/frontend/apps/mobile-shared/tsconfig.json new file mode 100644 index 0000000000..3f1ee434e4 --- /dev/null +++ b/packages/frontend/apps/mobile-shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.web.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["./src"], + "references": [{ "path": "../../core" }] +} diff --git a/packages/frontend/mobile-native/src/mobile_blob_cache.rs b/packages/frontend/mobile-native/src/mobile_blob_cache.rs index 7c86c76b00..87bfe29665 100644 --- a/packages/frontend/mobile-native/src/mobile_blob_cache.rs +++ b/packages/frontend/mobile-native/src/mobile_blob_cache.rs @@ -9,6 +9,7 @@ use std::{ use lru::LruCache; pub(crate) const MOBILE_BLOB_INLINE_THRESHOLD_BYTES: usize = 1024 * 1024; +const MOBILE_BLOB_MAX_READ_BYTES: u64 = 64 * 1024 * 1024; const MOBILE_BLOB_CACHE_CAPACITY: usize = 32; const MOBILE_BLOB_CACHE_DIR: &str = "nbstore-blob-cache"; pub(crate) const MOBILE_BLOB_FILE_PREFIX: &str = "__AFFINE_BLOB_FILE__:"; @@ -86,8 +87,7 @@ impl MobileBlobCache { pub(crate) fn cache_blob(&self, universal_id: &str, blob: &affine_nbstore::Blob) -> std::io::Result { let cache_key = Self::cache_key(universal_id, &blob.key); - let cache_dir = self.resolve_cache_dir(universal_id); - std::fs::create_dir_all(&cache_dir)?; + let cache_dir = self.ensure_cache_dir(universal_id)?; let file_path = Self::blob_file_path(&cache_dir, &cache_key); std::fs::write(&file_path, &blob.data)?; @@ -118,8 +118,7 @@ impl MobileBlobCache { data: &[u8], ) -> std::io::Result { let cache_key = Self::cache_key(universal_id, &format!("doc\u{1f}{doc_id}\u{1f}{timestamp}")); - let cache_dir = self.resolve_cache_dir(universal_id); - std::fs::create_dir_all(&cache_dir)?; + let cache_dir = self.ensure_cache_dir(universal_id)?; let file_path = Self::doc_file_path(&cache_dir, &cache_key); std::fs::write(&file_path, data)?; @@ -248,6 +247,12 @@ impl MobileBlobCache { .clone() } + fn ensure_cache_dir(&self, universal_id: &str) -> std::io::Result { + let cache_dir = self.resolve_cache_dir(universal_id); + std::fs::create_dir_all(&cache_dir)?; + Ok(cache_dir) + } + fn blob_file_path(cache_dir: &Path, cache_key: &str) -> PathBuf { let mut hasher = DefaultHasher::new(); cache_key.hash(&mut hasher); @@ -284,5 +289,62 @@ pub(crate) fn read_mobile_binary_file(value: &str) -> std::io::Result> { .strip_prefix(MOBILE_BLOB_FILE_PREFIX) .or_else(|| value.strip_prefix(MOBILE_DOC_FILE_PREFIX)) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid mobile file token"))?; - std::fs::read(path) + + let path = path.strip_prefix("file://").unwrap_or(path); + let canonical = std::fs::canonicalize(path)?; + if !is_valid_mobile_cache_path(&canonical) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "mobile file token points outside the nbstore cache directory", + )); + } + + let metadata = std::fs::metadata(&canonical)?; + if !metadata.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "mobile file token does not resolve to a file", + )); + } + if metadata.len() > MOBILE_BLOB_MAX_READ_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "mobile file token exceeds max size: {} > {}", + metadata.len(), + MOBILE_BLOB_MAX_READ_BYTES + ), + )); + } + + std::fs::read(canonical) +} + +fn is_valid_mobile_cache_path(path: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let Some((stem, extension)) = file_name.rsplit_once('.') else { + return false; + }; + if extension != "blob" && extension != "docbin" { + return false; + } + if stem.len() != 16 || !stem.chars().all(|c| c.is_ascii_hexdigit()) { + return false; + } + + let Some(workspace_bucket) = path.parent().and_then(Path::file_name).and_then(|name| name.to_str()) else { + return false; + }; + if workspace_bucket.len() != 16 || !workspace_bucket.chars().all(|c| c.is_ascii_hexdigit()) { + return false; + } + + path + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + == Some(MOBILE_BLOB_CACHE_DIR) } diff --git a/packages/frontend/native/nbstore/src/pool.rs b/packages/frontend/native/nbstore/src/pool.rs index 0710274c64..6ca77e6d90 100644 --- a/packages/frontend/native/nbstore/src/pool.rs +++ b/packages/frontend/native/nbstore/src/pool.rs @@ -55,16 +55,20 @@ impl SqliteDocStoragePool { pub async fn disconnect(&self, universal_id: String) -> Result<()> { let storage = { let mut lock = self.inner.write().await; - if let Entry::Occupied(entry) = lock.entry(universal_id) { - Some(entry.remove()) - } else { - None - } + lock.remove(&universal_id) }; - if let Some(storage) = storage { - storage.close().await; + let Some(storage) = storage else { + return Ok(()); + }; + + // Prevent shutting down the shared storage while requests still hold refs. + if Arc::strong_count(&storage) > 1 { + let mut lock = self.inner.write().await; + lock.insert(universal_id, storage); + return Err(Error::InvalidOperation); } + storage.close().await; Ok(()) } } diff --git a/packages/frontend/native/package.json b/packages/frontend/native/package.json index d065494e88..8e750dd44c 100644 --- a/packages/frontend/native/package.json +++ b/packages/frontend/native/package.json @@ -41,7 +41,7 @@ "build": "napi build -p affine_native --platform --release", "build:debug": "napi build -p affine_native --platform", "universal": "napi universal", - "test": "ava", + "test": "ava --no-worker-threads --concurrency=2", "version": "napi version" }, "version": "0.26.1" diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 4168f3b7cc..90533c1b34 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -1248,6 +1248,7 @@ export const PackageList = [ 'packages/frontend/core', 'packages/common/env', 'packages/frontend/i18n', + 'packages/frontend/apps/mobile-shared', 'packages/common/nbstore', 'packages/frontend/track', 'blocksuite/affine/all', @@ -1290,6 +1291,7 @@ export const PackageList = [ 'packages/common/env', 'packages/common/graphql', 'packages/frontend/i18n', + 'packages/frontend/apps/mobile-shared', 'packages/common/nbstore', 'packages/frontend/track', 'blocksuite/affine/all', @@ -1313,6 +1315,11 @@ export const PackageList = [ 'packages/common/infra', ], }, + { + location: 'packages/frontend/apps/mobile-shared', + name: '@affine/mobile-shared', + workspaceDependencies: ['packages/frontend/core'], + }, { location: 'packages/frontend/apps/web', name: '@affine/web', @@ -1593,6 +1600,7 @@ export type PackageName = | '@affine/electron-renderer' | '@affine/ios' | '@affine/mobile' + | '@affine/mobile-shared' | '@affine/web' | '@affine/component' | '@affine/core' diff --git a/tsconfig.json b/tsconfig.json index fe167fcb0d..a894d3145a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -139,6 +139,7 @@ { "path": "./packages/frontend/apps/electron-renderer" }, { "path": "./packages/frontend/apps/ios" }, { "path": "./packages/frontend/apps/mobile" }, + { "path": "./packages/frontend/apps/mobile-shared" }, { "path": "./packages/frontend/apps/web" }, { "path": "./packages/frontend/component" }, { "path": "./packages/frontend/core" }, diff --git a/yarn.lock b/yarn.lock index 3878fc364b..8f7452a78a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -255,6 +255,7 @@ __metadata: "@affine/core": "workspace:*" "@affine/env": "workspace:*" "@affine/i18n": "workspace:*" + "@affine/mobile-shared": "workspace:*" "@affine/nbstore": "workspace:*" "@affine/track": "workspace:*" "@blocksuite/affine": "workspace:*" @@ -703,6 +704,7 @@ __metadata: "@affine/env": "workspace:*" "@affine/graphql": "workspace:*" "@affine/i18n": "workspace:*" + "@affine/mobile-shared": "workspace:*" "@affine/native": "workspace:*" "@affine/nbstore": "workspace:*" "@affine/track": "workspace:*" @@ -764,6 +766,16 @@ __metadata: languageName: unknown linkType: soft +"@affine/mobile-shared@workspace:*, @affine/mobile-shared@workspace:packages/frontend/apps/mobile-shared": + version: 0.0.0-use.local + resolution: "@affine/mobile-shared@workspace:packages/frontend/apps/mobile-shared" + dependencies: + "@affine/core": "workspace:*" + "@capacitor/core": "npm:^7.0.0" + typescript: "npm:^5.7.2" + languageName: unknown + linkType: soft + "@affine/mobile@workspace:packages/frontend/apps/mobile": version: 0.0.0-use.local resolution: "@affine/mobile@workspace:packages/frontend/apps/mobile"