Files
HIPCTF2/tests/backend/uploads.spec.ts
T
2026-07-22 19:19:44 +00:00

226 lines
9.5 KiB
TypeScript

process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-uploads-test';
process.env.UPLOAD_SIZE_LIMIT = '1mb';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { safeFilename, parseUploadSizeLimit } from '../../backend/src/common/utils/upload';
describe('safeFilename', () => {
it('strips directory traversal', () => {
expect(safeFilename('../../etc/passwd')).not.toMatch(/\.\./);
expect(safeFilename('/etc/passwd')).not.toMatch(/^\//);
});
it('lowercases and restricts charset to [a-z0-9._-]', () => {
const out = safeFilename('My File (1).TXT');
expect(out).toMatch(/^[a-z0-9._-]+$/);
expect(out).toMatch(/\.txt$/);
});
it('always returns a non-empty unique name with a random suffix', () => {
const a = safeFilename('icon.png');
const b = safeFilename('icon.png');
expect(a).not.toBe(b);
expect(a).toMatch(/^icon-[0-9a-f]{8}\.png$/);
});
it('falls back to "file" when nothing usable is left', () => {
expect(safeFilename('///')).toMatch(/^file-[0-9a-f]{8}$/);
});
});
describe('parseUploadSizeLimit', () => {
it('parses kb/mb/gb', () => {
expect(parseUploadSizeLimit('1kb')).toBe(1024);
expect(parseUploadSizeLimit('5mb')).toBe(5 * 1024 * 1024);
expect(parseUploadSizeLimit('2gb')).toBe(2 * 1024 * 1024 * 1024);
expect(parseUploadSizeLimit('512b')).toBe(512);
});
it('defaults to 50mb on invalid input', () => {
expect(parseUploadSizeLimit(undefined)).toBe(50 * 1024 * 1024);
expect(parseUploadSizeLimit('garbage')).toBe(50 * 1024 * 1024);
});
});
// Integration: hit the real endpoint via supertest.
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { initDb } from './db-helper';
describe('Uploads endpoint integration', () => {
let app: INestApplication;
let adminToken: string;
beforeAll(async () => {
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
}
fs.mkdirSync(process.env.UPLOAD_DIR!, { recursive: true });
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
// Mirror production: serve /uploads static so we can verify public fetch.
app.use('/uploads', express.static(process.env.UPLOAD_DIR!));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
const server = app.getHttpServer();
await request(server).post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
const login = await request(server).post('/api/v1/auth/login')
.set('Cookie', `csrf=${csrfToken}`)
.set('X-CSRF-Token', csrfToken)
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminToken = login.body.accessToken;
});
afterAll(async () => {
await app.close();
if (fs.existsSync(process.env.UPLOAD_DIR!)) {
fs.rmSync(process.env.UPLOAD_DIR!, { recursive: true, force: true });
}
});
async function primeCsrf(): Promise<{ agent: any; csrf: string }> {
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
return { agent, csrf: cookies.find((c: any) => c.name === 'csrf').value };
}
it('rejects upload without auth (or CSRF)', async () => {
const res = await request(app.getHttpServer())
.post('/api/v1/uploads/category-icon')
.attach('file', Buffer.from('hello'), 'icon.png');
// Without a session AND without a CSRF cookie, the CSRF middleware
// short-circuits first with 403. Either 401 (auth) or 403 (csrf) is
// a valid rejection; the test asserts the upload is rejected.
expect([401, 403]).toContain(res.status);
});
it('uploads a valid image and writes a deterministic 128x128 PNG', async () => {
const { agent, csrf } = await primeCsrf();
const png = await sharp({
create: { width: 64, height: 64, channels: 3, background: { r: 1, g: 2, b: 3 } },
}).png().toBuffer();
const res = await agent
.post('/api/v1/uploads/category-icon')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.field('categoryId', 'icon-test-cat')
.attach('file', png, 'icon.png');
expect(res.status).toBe(201);
expect(res.body.id).toBe('icon-test-cat.png');
expect(res.body.publicUrl).toBe('/uploads/icons/icon-test-cat.png');
expect(res.body.width).toBe(128);
expect(res.body.height).toBe(128);
expect(res.body.mimeType).toBe('image/png');
const written = path.join(process.env.UPLOAD_DIR!, 'icons', 'icon-test-cat.png');
expect(fs.existsSync(written)).toBe(true);
const decoded = await sharp(written).metadata();
expect(decoded.format).toBe('png');
expect(decoded.width).toBe(128);
expect(decoded.height).toBe(128);
await request(app.getHttpServer()).get(res.body.publicUrl).expect(200);
});
it('rejects a plain-text payload labeled as a category icon and does not overwrite a valid icon', async () => {
const iconsDir = path.join(process.env.UPLOAD_DIR!, 'icons');
fs.mkdirSync(iconsDir, { recursive: true });
const target = path.join(iconsDir, 'icon-tx-cat.png');
const validPng = await sharp({
create: { width: 32, height: 32, channels: 3, background: { r: 9, g: 9, b: 9 } },
}).png().toBuffer();
fs.writeFileSync(target, validPng);
const beforeBytes = fs.readFileSync(target);
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/category-icon')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.field('categoryId', 'icon-tx-cat')
.attach('file', Buffer.from('not an image'), { filename: 'icon.png', contentType: 'image/png' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
expect(fs.existsSync(target)).toBe(true);
expect(fs.readFileSync(target).equals(beforeBytes)).toBe(true);
});
it('rejects a corrupt PNG payload and does not overwrite a valid icon', async () => {
const iconsDir = path.join(process.env.UPLOAD_DIR!, 'icons');
const target = path.join(iconsDir, 'icon-corrupt-cat.png');
const validPng = await sharp({
create: { width: 32, height: 32, channels: 3, background: { r: 5, g: 5, b: 5 } },
}).png().toBuffer();
fs.writeFileSync(target, validPng);
const beforeBytes = fs.readFileSync(target);
const { agent, csrf } = await primeCsrf();
const corrupt = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(53, 0x00)]);
const res = await agent
.post('/api/v1/uploads/category-icon')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.field('categoryId', 'icon-corrupt-cat')
.attach('file', corrupt, { filename: 'corrupt.png', contentType: 'image/png' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
expect(fs.readFileSync(target).equals(beforeBytes)).toBe(true);
});
it('rejects oversize uploads (limit=1mb, file=2mb)', async () => {
const { agent, csrf } = await primeCsrf();
const big = Buffer.alloc(2 * 1024 * 1024, 'a'); // 2MB
const res = await agent
.post('/api/v1/uploads/category-icon')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', big, 'big.bin');
// Multer's LIMIT_FILE_SIZE surfaces as 500 by default; we accept 4xx/5xx
// and just verify the file was NOT saved.
expect([400, 413, 500]).toContain(res.status);
const icons = fs.existsSync(path.join(process.env.UPLOAD_DIR!, 'icons'))
? fs.readdirSync(path.join(process.env.UPLOAD_DIR!, 'icons'))
: [];
expect(icons.some((f) => f === 'big.bin')).toBe(false);
});
it('stores files under per-route subdirectories', async () => {
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/challenge-file')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.from('attachment'), 'readme.txt');
expect(res.status).toBe(201);
expect(res.body.publicUrl).toMatch(/^\/uploads\/challenges\//);
});
});