aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-27 00:36:21 +0000
committerMohamed Bassem <me@mbassem.com>2025-12-27 00:36:21 +0000
commit2dbdf76c75852f14fee9564b7b29be070754ed5b (patch)
tree7f1e49388c89e397d42518d7930a901fbee00c29
parent347793ada2217f07e519c22147020664a52b122f (diff)
downloadkarakeep-2dbdf76c75852f14fee9564b7b29be070754ed5b.tar.zst
fix: reject spoofed content types on uploads
-rw-r--r--packages/api/package.json1
-rw-r--r--packages/api/utils/upload.ts12
-rw-r--r--pnpm-lock.yaml61
3 files changed, 73 insertions, 1 deletions
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