diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index f50c4e3c..e26eeda8 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -117,6 +117,7 @@ export default defineBuildConfig({ "./src/plugins/comments/query-keys.ts", // media plugin entries "./src/plugins/media/api/index.ts", + "./src/plugins/media/api/adapters/local.ts", "./src/plugins/media/api/adapters/s3.ts", "./src/plugins/media/api/adapters/vercel-blob.ts", "./src/plugins/media/client/index.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index 936f713a..05e69551 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.11.0", + "version": "2.11.1", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -424,6 +424,16 @@ "default": "./dist/plugins/media/api/index.cjs" } }, + "./plugins/media/api/adapters/local": { + "import": { + "types": "./dist/plugins/media/api/adapters/local.d.ts", + "default": "./dist/plugins/media/api/adapters/local.mjs" + }, + "require": { + "types": "./dist/plugins/media/api/adapters/local.d.cts", + "default": "./dist/plugins/media/api/adapters/local.cjs" + } + }, "./plugins/media/api/adapters/s3": { "import": { "types": "./dist/plugins/media/api/adapters/s3.d.ts", @@ -684,6 +694,9 @@ "plugins/media/api": [ "./dist/plugins/media/api/index.d.ts" ], + "plugins/media/api/adapters/local": [ + "./dist/plugins/media/api/adapters/local.d.ts" + ], "plugins/media/api/adapters/s3": [ "./dist/plugins/media/api/adapters/s3.d.ts" ], diff --git a/packages/stack/src/plugins/media/__tests__/storage-adapters.test.ts b/packages/stack/src/plugins/media/__tests__/storage-adapters.test.ts index dbc0d080..e52b9539 100644 --- a/packages/stack/src/plugins/media/__tests__/storage-adapters.test.ts +++ b/packages/stack/src/plugins/media/__tests__/storage-adapters.test.ts @@ -145,6 +145,17 @@ describe("localAdapter", () => { const adapter = localAdapter(); expect(adapter.type).toBe("local"); }); + + it("throws when a URL contains a path traversal sequence", async () => { + const uploadDir = await makeTmpDir(); + const adapter = localAdapter({ uploadDir, publicPath: "/uploads" }); + + // Simulate a tampered URL that would resolve outside uploadDir after decoding. + const maliciousUrl = "/uploads/..%2F..%2Fetc%2Fpasswd"; + await expect(adapter.delete(maliciousUrl)).rejects.toThrow( + "Refusing to delete file outside upload directory", + ); + }); }); // ── S3 adapter ──────────────────────────────────────────────────────────────── diff --git a/packages/stack/src/plugins/media/api/adapters/local.ts b/packages/stack/src/plugins/media/api/adapters/local.ts index f58bc2bd..9a799b2c 100644 --- a/packages/stack/src/plugins/media/api/adapters/local.ts +++ b/packages/stack/src/plugins/media/api/adapters/local.ts @@ -66,7 +66,16 @@ export function localAdapter( if (!encodedFilename) return; const filename = decodeURIComponent(encodedFilename); - const filePath = path.join(uploadDir, filename); + const resolvedUploadDir = path.resolve(uploadDir); + const filePath = path.join(resolvedUploadDir, filename); + + // Guard against path traversal: reject any path that escapes uploadDir. + if (!filePath.startsWith(resolvedUploadDir + path.sep)) { + throw new Error( + `Refusing to delete file outside upload directory: ${filePath}`, + ); + } + try { await fs.unlink(filePath); } catch (err: unknown) { diff --git a/packages/stack/src/plugins/media/api/index.ts b/packages/stack/src/plugins/media/api/index.ts index 63e9deae..5e97ea46 100644 --- a/packages/stack/src/plugins/media/api/index.ts +++ b/packages/stack/src/plugins/media/api/index.ts @@ -26,11 +26,6 @@ export { serializeAsset, serializeFolder } from "./serializers"; export { MEDIA_QUERY_KEYS, assetListDiscriminator } from "./query-key-defs"; -export { - localAdapter, - type LocalStorageAdapterOptions, -} from "./adapters/local"; - export type { StorageAdapter, DirectStorageAdapter, diff --git a/packages/stack/src/plugins/media/api/plugin.ts b/packages/stack/src/plugins/media/api/plugin.ts index ce126c93..b052ae1c 100644 --- a/packages/stack/src/plugins/media/api/plugin.ts +++ b/packages/stack/src/plugins/media/api/plugin.ts @@ -240,6 +240,10 @@ export const mediaBackendPlugin = (config: MediaBackendConfig) => listAssets: (params?: Parameters[1]) => listAssets(adapter, params), getAssetById: (id: string) => getAssetById(adapter, id), + createAsset: (input: Parameters[1]) => + createAsset(adapter, input), + updateAsset: (id: string, input: Parameters[2]) => + updateAsset(adapter, id, input), listFolders: (params?: Parameters[1]) => listFolders(adapter, params), getFolderById: (id: string) => getFolderById(adapter, id), @@ -248,6 +252,8 @@ export const mediaBackendPlugin = (config: MediaBackendConfig) => parentId?: string | null, tenantId?: string, ) => getFolderByName(adapter, name, parentId, tenantId), + createFolder: (input: Parameters[1]) => + createFolder(adapter, input), }), routes: (adapter: Adapter) => { diff --git a/scripts/codegen/files/nextjs/lib/stack.ts b/scripts/codegen/files/nextjs/lib/stack.ts index 50643a00..521ccca6 100644 --- a/scripts/codegen/files/nextjs/lib/stack.ts +++ b/scripts/codegen/files/nextjs/lib/stack.ts @@ -11,10 +11,8 @@ import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api"; import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api"; import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api"; import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api"; -import { - mediaBackendPlugin, - localAdapter, -} from "@btst/stack/plugins/media/api"; +import { mediaBackendPlugin } from "@btst/stack/plugins/media/api"; +import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local"; import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder"; import { openai } from "@ai-sdk/openai"; import { tool } from "ai"; diff --git a/scripts/codegen/files/react-router/app/lib/stack.ts b/scripts/codegen/files/react-router/app/lib/stack.ts index 792c92a6..f2e67d52 100644 --- a/scripts/codegen/files/react-router/app/lib/stack.ts +++ b/scripts/codegen/files/react-router/app/lib/stack.ts @@ -11,10 +11,8 @@ import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api"; import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api"; import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api"; import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api"; -import { - mediaBackendPlugin, - localAdapter, -} from "@btst/stack/plugins/media/api"; +import { mediaBackendPlugin } from "@btst/stack/plugins/media/api"; +import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local"; import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder"; import { openai } from "@ai-sdk/openai"; import { tool } from "ai"; diff --git a/scripts/codegen/files/tanstack/src/lib/stack.ts b/scripts/codegen/files/tanstack/src/lib/stack.ts index d68e5fc8..463cb493 100644 --- a/scripts/codegen/files/tanstack/src/lib/stack.ts +++ b/scripts/codegen/files/tanstack/src/lib/stack.ts @@ -11,10 +11,8 @@ import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api"; import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api"; import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api"; import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api"; -import { - mediaBackendPlugin, - localAdapter, -} from "@btst/stack/plugins/media/api"; +import { mediaBackendPlugin } from "@btst/stack/plugins/media/api"; +import { localAdapter } from "@btst/stack/plugins/media/api/adapters/local"; import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder"; import { openai } from "@ai-sdk/openai"; import { tool } from "ai";