Files
hedgedoc/backend/test/private-api/private-api.media.e2e-spec.ts
T
Erik Michelson 6c5b44eaec feat(media): serve uploads from local filesystem with permission check
Previously, the permission checks of the /media/:uuid route could be
bypassed for the local filesystem as a media backend, because the
/media/:uuid route performed a redirect to the target after checking
the permissions. Since the target was always /uploads/:uuid.ext where
ext was the extension of the uploaded file, you could simply try to
guess the file extension and have access to the file since the
/uploads endpoint was simply the complete uploads folder mounted.
Now, media uploads from the local filesystem backend are served
under the /media route directly instead of a redirect.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
2026-07-24 22:17:00 +02:00

339 lines
12 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { promises as fs } from 'fs';
import { join } from 'path';
import type request from 'supertest';
import { PRIVATE_API_PREFIX } from '../../src/app.module';
import type { TestSetup } from '../test-setup';
import { noteAlias1, TestSetupBuilder } from '../test-setup';
import { ensureDeleted } from '../utils';
import { setupAgent } from './utils/setup-agent';
import { MediaUploadDto } from '../../src/dtos/media-upload.dto';
import { SpecialGroup } from '@hedgedoc/commons';
type PartialMediaUploadResponse = Pick<MediaUploadDto, 'fileName' | 'linkedNoteCount'>;
describe('Media', () => {
let testSetup: TestSetup;
let uploadPath: string;
let agentNotLoggedIn: request.SuperAgentTest;
let agentGuestUser: request.SuperAgentTest;
let agentUser1: request.SuperAgentTest;
let agentUser2: request.SuperAgentTest;
let userId: number;
let testImage: Buffer;
const testFileName = 'test.png';
beforeEach(async () => {
testSetup = await TestSetupBuilder.create().withUsers().withNotes().build();
uploadPath = testSetup.configService.get('mediaConfig').backend.filesystem.uploadPath;
await testSetup.init();
[agentNotLoggedIn, agentGuestUser, agentUser1, agentUser2] = await setupAgent(testSetup);
userId = testSetup.userIds[0];
testImage = await fs.readFile('test/private-api/fixtures/test.png');
});
afterEach(async () => {
// Delete the upload folder
await ensureDeleted(uploadPath);
await testSetup.cleanup();
});
describe(`POST ${PRIVATE_API_PREFIX}/media`, () => {
describe('works', () => {
let uuid: string;
afterEach(async () => {
await fs.unlink(join(uploadPath, uuid + '.png'));
await ensureDeleted(uploadPath);
});
it('with user', async () => {
const uploadResponse = await agentUser1
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.png')
.set('HedgeDoc-Note', noteAlias1)
.expect(201);
uuid = uploadResponse.body.uuid;
const downloadResponse = await agentUser1
.get(`/media/${uuid}`)
.buffer(true)
.parse((response, callback) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => callback(null, Buffer.concat(chunks)));
});
expect(downloadResponse.statusCode).toEqual(200);
expect(downloadResponse.body).toEqual(testImage);
});
it('with user and uppercase note alias', async () => {
const uploadResponse = await agentUser1
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.png')
.set('HedgeDoc-Note', noteAlias1.toUpperCase())
.expect(201);
uuid = uploadResponse.body.uuid;
const downloadResponse = await agentUser1
.get(`/media/${uuid}`)
.buffer(true)
.parse((response, callback) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => callback(null, Buffer.concat(chunks)));
});
expect(downloadResponse.statusCode).toEqual(200);
expect(downloadResponse.body).toEqual(testImage);
});
it('with guest user', async () => {
const noteDtoResponse = await agentGuestUser
.post(`${PRIVATE_API_PREFIX}/notes`)
.set('Content-Type', 'text/markdown')
.send('')
.expect('Content-Type', /json/)
.expect(201);
const uploadResponse = await agentGuestUser
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.png')
.set('HedgeDoc-Note', noteDtoResponse.body.metadata.primaryAlias)
.expect(201);
uuid = uploadResponse.body.uuid;
const downloadResponse = await agentGuestUser
.get(`/media/${uuid}`)
.buffer(true)
.parse((response, callback) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => callback(null, Buffer.concat(chunks)));
});
expect(downloadResponse.statusCode).toEqual(200);
expect(downloadResponse.body).toEqual(testImage);
});
it('with guest user and uppercase note alias', async () => {
const noteDtoResponse = await agentGuestUser
.post(`${PRIVATE_API_PREFIX}/notes`)
.set('Content-Type', 'text/markdown')
.send('')
.expect('Content-Type', /json/)
.expect(201);
const uploadResponse = await agentGuestUser
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.png')
.set('HedgeDoc-Note', noteDtoResponse.body.metadata.primaryAlias.toUpperCase())
.expect(201);
uuid = uploadResponse.body.uuid;
const downloadResponse = await agentGuestUser
.get(`/media/${uuid}`)
.buffer(true)
.parse((response, callback) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => callback(null, Buffer.concat(chunks)));
});
expect(downloadResponse.statusCode).toEqual(200);
expect(downloadResponse.body).toEqual(testImage);
});
});
describe('fails:', () => {
afterEach(async () => {
await ensureDeleted(uploadPath);
});
it('if user is not logged-in', async () => {
await agentNotLoggedIn
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.png')
.set('HedgeDoc-Note', noteAlias1)
.expect(401);
});
it('MIME type not supported', async () => {
await agentUser1
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.zip')
.set('HedgeDoc-Note', noteAlias1)
.expect(400);
await expect(fs.access(uploadPath)).rejects.toBeDefined();
});
it('note does not exist', async () => {
await agentUser1
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.zip')
.set('HedgeDoc-Note', 'i_dont_exist')
.expect(404);
await expect(fs.access(uploadPath)).rejects.toBeDefined();
});
it('mediaBackend error', async () => {
await fs.mkdir(uploadPath, {
mode: '444',
});
await agentUser1
.post(`${PRIVATE_API_PREFIX}/media`)
.attach('file', 'test/private-api/fixtures/test.png')
.set('HedgeDoc-Note', noteAlias1)
.expect('Content-Type', /json/)
.expect(500);
});
it('no media uploaded', async () => {
await agentUser1
.post(`${PRIVATE_API_PREFIX}/media`)
.set('HedgeDoc-Note', noteAlias1)
.expect(400);
await expect(fs.access(uploadPath)).rejects.toBeDefined();
});
});
});
describe(`GET ${PRIVATE_API_PREFIX}/media/:uuid`, () => {
let mediaUploadUuid: string;
beforeEach(async () => {
mediaUploadUuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
userId,
testSetup.ownedNoteIds[0],
);
// Make note non-readable to guests to test access forbidden case
const specialGroupEveryoneId = await testSetup.groupService.getGroupIdByName(
SpecialGroup.EVERYONE,
);
await testSetup.permissionsService.removeGroupPermission(
testSetup.ownedNoteIds[0],
specialGroupEveryoneId,
);
});
it('returns correct data for owner', async () => {
// agentUser1 is the uploader and therefore owner
const response = await agentUser1
.get(`${PRIVATE_API_PREFIX}/media/${mediaUploadUuid}`)
.expect(200);
expect(response.headers['content-type']).toContain('application/json');
const jsonResponse = response.body as PartialMediaUploadResponse;
expect(jsonResponse.fileName).toEqual(testFileName);
expect(jsonResponse.linkedNoteCount).toEqual(1);
});
it('returns correct data for user with read-access to a linked note', async () => {
// agentUser2 has read-permission to the note via the logged-in special group
const response = await agentUser2
.get(`${PRIVATE_API_PREFIX}/media/${mediaUploadUuid}`)
.expect(200);
expect(response.headers['content-type']).toContain('application/json');
const jsonResponse = response.body as PartialMediaUploadResponse;
expect(jsonResponse.fileName).toEqual(testFileName);
expect(jsonResponse.linkedNoteCount).toEqual(1);
});
it('rejects with PermissionError for user with no read-access to a linked note', async () => {
await agentGuestUser.get(`${PRIVATE_API_PREFIX}/media/${mediaUploadUuid}`).expect(403);
});
});
describe(`DELETE ${PRIVATE_API_PREFIX}/media/:filename`, () => {
it('allowed if user is owner of file', async () => {
const uuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
userId,
testSetup.ownedNoteIds[0],
);
await agentUser1.get(`/media/${uuid}`).expect(200);
await agentUser1.delete(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(204);
await agentUser1.get(`/media/${uuid}`).expect(404);
});
it('allowed if user is owner of note', async () => {
const uuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
testSetup.userIds[1],
testSetup.ownedNoteIds[0],
);
await agentUser1.get(`/media/${uuid}`).expect(200);
await agentUser1.delete(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(204);
await agentUser1.get(`/media/${uuid}`).expect(404);
});
it("other user can't delete", async () => {
const uuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
await agentUser2.delete(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(403);
});
it("guest user can't delete", async () => {
const uuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
await agentGuestUser.delete(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(403);
});
});
describe(`PUT ${PRIVATE_API_PREFIX}/media/:uuid/notes/:noteAlias`, () => {
it('links media to a note by alias', async () => {
const targetAlias = 'media_link_target';
const targetNoteId = await testSetup.notesService.createNote(
'test content',
testSetup.userIds[0],
targetAlias,
);
const uuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
await agentUser1
.put(`${PRIVATE_API_PREFIX}/media/${uuid}/notes/${targetAlias.toUpperCase()}`)
.expect(204);
const mediaResponse = await agentUser1.get(`${PRIVATE_API_PREFIX}/media/${uuid}`).expect(200);
expect(mediaResponse.body.linkedNoteCount).toEqual(2);
const linkedUploads = await testSetup.mediaService.getMediaUploadUuidsByNoteId(targetNoteId);
expect(linkedUploads).toContain(uuid);
});
it('rejects users who are not the uploader', async () => {
const targetAlias = 'media_link_foreign_uploader';
await testSetup.notesService.createNote('test content', testSetup.userIds[1], targetAlias);
const uuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
await agentUser2.put(`${PRIVATE_API_PREFIX}/media/${uuid}/notes/${targetAlias}`).expect(403);
});
it('returns 404 for unknown note aliases', async () => {
const uuid = await testSetup.mediaService.saveFile(
testFileName,
testImage,
testSetup.userIds[0],
testSetup.ownedNoteIds[0],
);
await agentUser1.put(`${PRIVATE_API_PREFIX}/media/${uuid}/notes/does_not_exist`).expect(404);
});
});
});