mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
fix: MCP cannot upload files with local storage (#13023)
* fix: MCP cannot upload files with local storage * refactor
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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) || "",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user