fix: MCP cannot upload files with local storage (#13023)

* fix: MCP cannot upload files with local storage

* refactor
This commit is contained in:
Tom Moor
2026-07-16 20:59:49 -04:00
committed by GitHub
parent 9cd0539701
commit 03c7576b93
4 changed files with 145 additions and 3 deletions
+105
View File
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import FormData from "form-data";
import { ensureDirSync } from "fs-extra";
import JWT from "jsonwebtoken";
import { FileOperationState, FileOperationType } from "@shared/types";
import env from "@server/env";
import AttachmentHelper, {
@@ -194,6 +195,110 @@ describe("#files.create", () => {
existsSync(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, attachment.key))
).toBe(true);
});
it("should succeed with a valid upload signature and no session", async () => {
const user = await buildUser();
const fileName = "images.docx";
const attachment = await buildAttachment(
{
teamId: user.teamId,
userId: user.id,
contentType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
fileName
);
const sig = JWT.sign(
{ key: attachment.key, type: "attachment-upload" },
env.SECRET_KEY,
{ expiresIn: 3600 }
);
const content = await readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName)
);
const form = new FormData();
form.append("key", attachment.key);
form.append("file", content, fileName);
form.append("sig", sig);
const res = await server.post(`/api/files.create`, {
headers: form.getHeaders(),
body: form,
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(
existsSync(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, attachment.key))
).toBe(true);
});
it("should fail with a signature scoped to a different key", async () => {
const user = await buildUser();
const fileName = "images.docx";
const attachment = await buildAttachment(
{
teamId: user.teamId,
userId: user.id,
contentType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
fileName
);
const sig = JWT.sign(
{ key: "public/foo/bar/other.png", type: "attachment-upload" },
env.SECRET_KEY,
{ expiresIn: 3600 }
);
const content = await readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName)
);
const form = new FormData();
form.append("key", attachment.key);
form.append("file", content, fileName);
form.append("sig", sig);
const res = await server.post(`/api/files.create`, {
headers: form.getHeaders(),
body: form,
});
expect(res.status).toEqual(401);
expect(
existsSync(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, attachment.key))
).toBe(false);
});
it("should fail with status 401 when neither session nor signature is provided", async () => {
const user = await buildUser();
const fileName = "images.docx";
const attachment = await buildAttachment(
{
teamId: user.teamId,
userId: user.id,
contentType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
fileName
);
const content = await readFile(
path.resolve(__dirname, "..", "test", "fixtures", fileName)
);
const form = new FormData();
form.append("key", attachment.key);
form.append("file", content, fileName);
const res = await server.post(`/api/files.create`, {
headers: form.getHeaders(),
body: form,
});
expect(res.status).toEqual(401);
});
});
describe("#files.get", () => {
+33 -3
View File
@@ -30,7 +30,7 @@ const router = new Router();
router.post(
"files.create",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
auth({ optional: true }),
validate(T.FilesCreateSchema),
timeout(30 * 60 * 1000), // 30 minutes for large file uploads
multipart({
@@ -41,19 +41,27 @@ router.post(
}),
async (ctx: APIContext<T.FilesCreateReq>) => {
const actor = ctx.state.auth.user;
const { key } = ctx.input.body;
const { key, sig } = ctx.input.body;
const file = ctx.input.file;
if (!file) {
throw ValidationError("Request must include a file parameter");
}
// A short-lived signature authorizes the upload to this key without a session.
if (sig) {
verifyUploadSignature(sig, key);
} else if (!actor) {
throw AuthenticationError("Authentication required");
}
const attachment = await Attachment.findOne({
where: { key },
rejectOnEmpty: true,
});
if (attachment.userId !== actor.id) {
// For session-based uploads, ensure the attachment belongs to the actor.
if (!sig && actor && attachment.userId !== actor.id) {
throw AuthorizationError("Invalid key");
}
@@ -186,6 +194,28 @@ function getByteRange(
return { start, end };
}
/**
* Verifies a short-lived signature authorizing an upload to a single key.
*
* @param sig The signature to verify.
* @param key The key the upload is being made to.
* @throws AuthenticationError if the signature is invalid, expired, or scoped
* to a different key.
*/
function verifyUploadSignature(sig: string, key: string) {
const payload = getJWTPayload(sig);
if (payload.type !== "attachment-upload" || payload.key !== key) {
throw AuthenticationError("Invalid signature");
}
try {
JWT.verify(sig, env.SECRET_KEY);
} catch (_err) {
throw AuthenticationError("Invalid signature");
}
}
function getKeyFromContext(ctx: APIContext<T.FilesGetReq>): string {
const { key, sig } = ctx.input.query;
if (sig) {
+1
View File
@@ -9,6 +9,7 @@ export const FilesCreateSchema = z.object({
.string()
.refine(ValidateKey.isValid, { message: ValidateKey.message })
.transform(ValidateKey.sanitize),
sig: z.string().optional(),
}),
file: z.custom<formidable.File>().optional(),
});
+6
View File
@@ -23,6 +23,11 @@ export default class LocalStorage extends BaseStorage {
maxUploadSize: number,
contentType = "image"
): Promise<Partial<PresignedPost>> {
// A short-lived signature that authorizes uploading to this key only
const sig = JWT.sign({ key, type: "attachment-upload" }, env.SECRET_KEY, {
expiresIn: 3600,
});
return Promise.resolve({
url: this.getUrlForKey(key),
fields: {
@@ -30,6 +35,7 @@ export default class LocalStorage extends BaseStorage {
acl,
maxUploadSize: String(maxUploadSize),
contentType,
sig,
[CSRF.fieldName]: ctx.cookies.get(CSRF.cookieName) || "",
},
});