From 2dbdf76c75852f14fee9564b7b29be070754ed5b Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 27 Dec 2025 00:36:21 +0000 Subject: fix: reject spoofed content types on uploads --- packages/api/package.json | 1 + packages/api/utils/upload.ts | 12 ++++++++- pnpm-lock.yaml | 61 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/api/package.json b/packages/api/package.json index c9f34bcb..b5d90f03 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -22,6 +22,7 @@ "@karakeep/trpc": "workspace:*", "@trpc/server": "^11.4.3", "drizzle-orm": "^0.44.2", + "file-type": "^21.2.0", "hono": "^4.10.6", "prom-client": "^15.1.3", "rss": "^1.2.2", diff --git a/packages/api/utils/upload.ts b/packages/api/utils/upload.ts index a843e29c..b82bc855 100644 --- a/packages/api/utils/upload.ts +++ b/packages/api/utils/upload.ts @@ -3,6 +3,7 @@ import * as os from "os"; import * as path from "path"; import { Readable } from "stream"; import { pipeline } from "stream/promises"; +import { fileTypeFromBlob, supportedMimeTypes } from "file-type"; import { assets, AssetTypes } from "@karakeep/db/schema"; import { QuotaService, StorageQuotaError } from "@karakeep/shared-server"; @@ -58,7 +59,16 @@ export async function uploadAsset( data = formData.image; } - const contentType = data.type; + const detectedType = await fileTypeFromBlob(data); + const fallbackType = + data.type && data.type.trim().length > 0 ? data.type : null; + // Security: reject browser-provided MIME when we cannot sniff a valid type. + if (fallbackType && supportedMimeTypes.has(fallbackType) && !detectedType) { + return { error: "Unsupported asset type", status: 400 }; + } + const contentType = + detectedType?.mime ?? fallbackType ?? "application/octet-stream"; + // Replace all non-ascii characters with underscores const fileName = data.name.replace(/[^\x20-\x7E]/g, "_"); if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b16aae0..8a8d55e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1038,6 +1038,9 @@ importers: drizzle-orm: specifier: ^0.44.2 version: 0.44.2(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.3.0)(gel@2.1.0)(kysely@0.28.5) + file-type: + specifier: ^21.2.0 + version: 21.2.0 hono: specifier: ^4.10.6 version: 4.10.6 @@ -2592,6 +2595,9 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.1.1': + resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -5862,6 +5868,13 @@ packages: peerDependencies: react: ^18 || ^19 + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@trpc/client@11.4.3': resolution: {integrity: sha512-i2suttUCfColktXT8bqex5kHW5jpT15nwUh0hGSDiW1keN621kSUQKcLJ095blqQAUgB+lsmgSqSMmB4L9shQQ==} peerDependencies: @@ -8700,6 +8713,10 @@ packages: resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} engines: {node: '>= 12'} + file-type@21.2.0: + resolution: {integrity: sha512-vCYBgFOrJQLoTzDyAXAL/RFfKnXXpUYt4+tipVy26nJJhT7ftgGETf2tAQF59EEL61i3MrorV/PG6tf7LJK7eg==} + engines: {node: '>=20'} + file-type@3.9.0: resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} engines: {node: '>=0.10.0'} @@ -13784,6 +13801,10 @@ packages: strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -14041,6 +14062,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.1: + resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==} + engines: {node: '>=14.16'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -14234,6 +14259,10 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -16924,6 +16953,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@borewit/text-codec@0.1.1': {} + '@colors/colors@1.5.0': optional: true @@ -21224,6 +21255,15 @@ snapshots: '@tanstack/query-core': 5.90.2 react: 19.1.0 + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.1 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@trpc/client@11.4.3(@trpc/server@11.4.3(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@trpc/server': 11.4.3(typescript@5.9.3) @@ -24457,6 +24497,15 @@ snapshots: dependencies: tslib: 2.8.1 + file-type@21.2.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.1 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + file-type@3.9.0: {} file-uri-to-path@1.0.0: {} @@ -30910,6 +30959,10 @@ snapshots: strnum@1.1.2: {} + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + structured-headers@0.4.1: {} style-to-js@1.1.16: @@ -31215,6 +31268,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.1: + dependencies: + '@borewit/text-codec': 0.1.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + totalist@3.0.1: {} tough-cookie@4.1.4: @@ -31393,6 +31452,8 @@ snapshots: ufo@1.6.1: {} + uint8array-extras@1.5.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 -- cgit v1.2.3-70-g09d2