Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 2 additions & 32 deletions backend/controllers/backoffice/backoffice-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,7 @@ export class BackofficeController {
const previousPublicId =
type === 'original' ? pdfInfo?.original_pdf_public_id : pdfInfo?.validated_pdf_public_id

// Type, bytes and size were checked by singleInvoicePdf (backofficeUpload.ts)
if (!req.file) {
res.status(400).json({
success: false,
Expand All @@ -1106,27 +1107,6 @@ export class BackofficeController {
return
}

// Validar tipo de archivo
if (req.file.mimetype !== 'application/pdf') {
res.status(400).json({
success: false,
error: ERROR_CODES.BACKOFFICE_PDF_ONLY,
code: ERROR_CODES.BACKOFFICE_PDF_ONLY,
})
return
}

// Validar tamaño (máx 10MB)
const maxSize = 10 * 1024 * 1024
if (req.file.size > maxSize) {
res.status(400).json({
success: false,
error: ERROR_CODES.BACKOFFICE_FILE_TOO_LARGE,
code: ERROR_CODES.BACKOFFICE_FILE_TOO_LARGE,
})
return
}

logger.debug('[BackofficeController.uploadInvoicePdf] Uploading to Cloudinary...')

// Subir a Cloudinary (usar uploadPdf para archivos PDF)
Expand Down Expand Up @@ -1282,6 +1262,7 @@ export class BackofficeController {
return
}

// Type, bytes and size were checked by singleAssetImage (backofficeUpload.ts)
if (!req.file) {
res.status(400).json({
success: false,
Expand All @@ -1291,17 +1272,6 @@ export class BackofficeController {
return
}

// Validar tipo de archivo
const allowedTypes = ['image/png', 'image/webp']
if (!allowedTypes.includes(req.file.mimetype)) {
res.status(400).json({
success: false,
error: ERROR_CODES.BACKOFFICE_INVALID_IMAGE_TYPE,
code: ERROR_CODES.BACKOFFICE_INVALID_IMAGE_TYPE,
})
return
}

// Subir a Cloudinary
const cloudinaryResult = await CloudinaryService.uploadImage(
req.file.buffer,
Expand Down
69 changes: 69 additions & 0 deletions backend/middlewares/backofficeUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// middlewares/backofficeUpload.ts

import type { Request, Response, NextFunction, RequestHandler } from 'express'
import multer from 'multer'
import { ERROR_CODES } from '../config/error-codes.js'
import { isPdfBuffer } from '../services/uploads/pdf-signature.js'
import { detectImageType } from '../services/uploads/image-signature.js'

export const MAX_BACKOFFICE_FILE_BYTES = 10 * 1024 * 1024

// Stamps and signatures are laid over the invoice PDF: only formats with
// transparency
const ASSET_IMAGE_TYPES = ['image/png', 'image/webp']

/**
* One file in `field`, at most 10 MB: an oversized file answers 413 and one
* whose declared type or bytes `accepts` refuses answers 400 with `wrongType`.
* A request without a file goes on to the controller, which answers it.
*/
function singleFile(
field: string,
accepts: (file: Express.Multer.File) => boolean,
wrongType: string
): RequestHandler {
// Multer stops reading at the limit and rejects a file whose size reaches
// fileSize, hence the +1: exactly 10 MB stays valid
const handler = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_BACKOFFICE_FILE_BYTES + 1, files: 1 },
}).single(field)

return (req: Request, res: Response, next: NextFunction) => {
handler(req, res, (err?: unknown) => {
const fail = (status: number, code: string) => {
res.status(status).json({ success: false, error: code, code })
}
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') fail(413, ERROR_CODES.BACKOFFICE_FILE_TOO_LARGE)
else fail(400, wrongType)
return
}
if (!err && req.file && !accepts(req.file)) {
fail(400, wrongType)
return
}
next(err)
})
}
}

/** The invoice PDF: declared as PDF and starting like one. */
export function singleInvoicePdf(field: string): RequestHandler {
return singleFile(
field,
(file) => file.mimetype === 'application/pdf' && isPdfBuffer(file.buffer),
ERROR_CODES.BACKOFFICE_PDF_ONLY
)
}

/** A stamp or signature: a PNG or WebP by its declared type and its bytes. */
export function singleAssetImage(field: string): RequestHandler {
return singleFile(
field,
(file) =>
ASSET_IMAGE_TYPES.includes(file.mimetype) &&
ASSET_IMAGE_TYPES.includes(detectImageType(file.buffer) ?? ''),
ERROR_CODES.BACKOFFICE_INVALID_IMAGE_TYPE
)
}
14 changes: 3 additions & 11 deletions backend/routes/backoffice/backoffice-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,15 @@
*/

import { Router } from 'express'
import multer from 'multer'
import { BackofficeController } from '../../controllers/backoffice/backoffice-controller.js'
import { authenticateToken } from '../../middlewares/authenticateToken.js'
import { canAccessBackoffice, isAdmin } from '../../middlewares/roleCheck.js'
import { denyDemo } from '../../middlewares/demoRestriction.js'
import { requireStorage } from '../../middlewares/requireStorage.js'
import { singleAssetImage, singleInvoicePdf } from '../../middlewares/backofficeUpload.js'

const router = Router()

// Configurar multer para uploads en memoria
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB para PDFs
},
})

// Aplicar autenticación y verificación de acceso a backoffice
router.use(authenticateToken)
router.use(canAccessBackoffice)
Expand Down Expand Up @@ -267,7 +259,7 @@ router.post(
'/invoices/:id/pdf',
isAdmin,
requireStorage,
upload.single('pdf'),
singleInvoicePdf('pdf'),
BackofficeController.uploadInvoicePdf
)

Expand Down Expand Up @@ -310,7 +302,7 @@ router.post(
'/assets',
isAdmin,
requireStorage,
upload.single('image'),
singleAssetImage('image'),
BackofficeController.createAsset
)

Expand Down
7 changes: 1 addition & 6 deletions backend/services/fnb/pdf-parser.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pdfParse from 'pdf-parse'
import { trackedCodesSet } from './fnb-categories.cache.js'
import { isPdfBuffer } from '../uploads/pdf-signature.js'

export interface ParsedPdfData {
date: string | null // YYYY-MM-DD
Expand All @@ -13,16 +14,10 @@ export class FnbPdfError extends Error {
status = 422
}

const PDF_MAGIC = Buffer.from('%PDF-')
// Opera's F&B report is 3 pages; anything far longer is not that report
const MAX_PAGES = 20
export const PARSE_TIMEOUT_MS = 15_000

// PDF readers accept the header anywhere in the first 1024 bytes
export function isPdfBuffer(buffer: Buffer): boolean {
return buffer.subarray(0, 1024).includes(PDF_MAGIC)
}

// Stops waiting for the parser; pdf.js cannot be aborted, so the page limit is
// what bounds the work left running in the background
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
Expand Down
11 changes: 11 additions & 0 deletions backend/services/uploads/pdf-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// services/uploads/pdf-signature.ts

const PDF_MAGIC = Buffer.from('%PDF-')

/**
* True when the bytes are a PDF. PDF readers accept the header anywhere in the
* first 1024 bytes; the declared mimetype is whatever the client says.
*/
export function isPdfBuffer(buffer: Buffer): boolean {
return buffer.subarray(0, 1024).includes(PDF_MAGIC)
}
8 changes: 7 additions & 1 deletion backend/services/uploads/private-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ export interface FetchedFile {
contentType: string
}

// No upload is over 10 MB (backoffice PDFs; images are 5 MB): the margin covers
// the stored format, and anything bigger is not one of our files
export const MAX_STORED_FILE_BYTES = 20 * 1024 * 1024

/**
* Downloads a stored Cloudinary URL. Throws if the URL is not in our cloud
* (SSRF) or Cloudinary does not answer 2xx. No redirects are followed.
* (SSRF), Cloudinary does not answer 2xx or the file is over
* MAX_STORED_FILE_BYTES. No redirects are followed.
*/
export async function fetchStoredFile(storedUrl: string): Promise<FetchedFile> {
if (!isOwnCloudinaryUrl(storedUrl)) {
Expand All @@ -26,6 +31,7 @@ export async function fetchStoredFile(storedUrl: string): Promise<FetchedFile> {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 0,
maxContentLength: MAX_STORED_FILE_BYTES,
})
const contentType = String(response.headers['content-type'] || 'application/octet-stream')
return { buffer: Buffer.from(response.data), contentType }
Expand Down
151 changes: 151 additions & 0 deletions backend/tests/backoffice/upload-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// tests/backoffice/upload-guard.test.ts
// Regression tests for the backoffice uploads: the invoice PDF and the stamp or
// signature image were accepted by their declared type alone (any file sent
// as application/pdf went to Cloudinary), and a PDF over 10 MB answered 500
// because the multer error reached the global handler.
// No database: real multipart requests to a real Express app with multer.

import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import {
MAX_BACKOFFICE_FILE_BYTES,
singleAssetImage,
singleInvoicePdf,
} from '../../middlewares/backofficeUpload.js'

let server: Server
let base: string

beforeAll(async () => {
const app = express()
// Same fields as routes/backoffice/backoffice-routes.ts
app.post('/pdf', singleInvoicePdf('pdf'), (req, res) => {
res.status(201).json({ size: req.file?.size })
})
app.post('/assets', singleAssetImage('image'), (req, res) => {
res.status(201).json({ size: req.file?.size })
})
server = app.listen(0)
await new Promise((resolve) => server.once('listening', resolve))
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})

afterAll(() => {
server.close()
})

const PDF_START = Buffer.from('%PDF-1.7\n')
const PNG_START = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const WEBP_START = Buffer.from('RIFF\0\0\0\0WEBP')
const JPEG_START = Buffer.from([0xff, 0xd8, 0xff, 0xe0])

// A file of `bytes` bytes that starts with `start`
function file(bytes: number, start: Buffer = Buffer.alloc(0)): Uint8Array {
const data = new Uint8Array(bytes)
data.set(start)
return data
}

async function upload(path: string, field: string, data: Uint8Array, type: string) {
const form = new FormData()
form.append(field, new Blob([data], { type }), 'fichero')
const res = await fetch(`${base}${path}`, { method: 'POST', body: form })
return { status: res.status, body: await res.json() }
}

describe('singleInvoicePdf', () => {
it('accepts a PDF up to 10 MB', async () => {
const res = await upload(
'/pdf',
'pdf',
file(MAX_BACKOFFICE_FILE_BYTES, PDF_START),
'application/pdf'
)
expect(res).toEqual({ status: 201, body: { size: MAX_BACKOFFICE_FILE_BYTES } })
})

it('refuses a file declared as PDF whose bytes are not a PDF', async () => {
const res = await upload('/pdf', 'pdf', file(1024, Buffer.from('MZ')), 'application/pdf')
expect(res.status).toBe(400)
expect(res.body.code).toBe('BACKOFFICE_PDF_ONLY')
})

it('refuses a real PDF declared with another type', async () => {
const res = await upload('/pdf', 'pdf', file(1024, PDF_START), 'text/html')
expect(res.status).toBe(400)
expect(res.body.code).toBe('BACKOFFICE_PDF_ONLY')
})

it('answers 413, not 500, over 10 MB', async () => {
const res = await upload(
'/pdf',
'pdf',
file(MAX_BACKOFFICE_FILE_BYTES + 1, PDF_START),
'application/pdf'
)
expect(res.status).toBe(413)
expect(res.body.code).toBe('BACKOFFICE_FILE_TOO_LARGE')
})

it('leaves a request without a file to the controller', async () => {
const res = await fetch(`${base}/pdf`, { method: 'POST', body: new FormData() })
expect(res.status).toBe(201)
})
})

describe('singleAssetImage', () => {
it.each([
['PNG', PNG_START, 'image/png'],
['WebP', WEBP_START, 'image/webp'],
])('accepts a %s', async (_name, start, type) => {
expect((await upload('/assets', 'image', file(2048, start), type)).status).toBe(201)
})

it('refuses a JPEG renamed as PNG: the stamp needs transparency', async () => {
const res = await upload('/assets', 'image', file(2048, JPEG_START), 'image/png')
expect(res.status).toBe(400)
expect(res.body.code).toBe('BACKOFFICE_INVALID_IMAGE_TYPE')
})

it('refuses anything else declared as PNG', async () => {
const res = await upload('/assets', 'image', file(2048, PDF_START), 'image/png')
expect(res.status).toBe(400)
expect(res.body.code).toBe('BACKOFFICE_INVALID_IMAGE_TYPE')
})

it('answers 413 over 10 MB', async () => {
const res = await upload(
'/assets',
'image',
file(MAX_BACKOFFICE_FILE_BYTES + 1, PNG_START),
'image/png'
)
expect(res.status).toBe(413)
expect(res.body.code).toBe('BACKOFFICE_FILE_TOO_LARGE')
})
})

describe('routes', () => {
const routes = readFileSync(
join(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'routes',
'backoffice',
'backoffice-routes.ts'
),
'utf8'
)

it('use the guarded uploads, not a bare multer', () => {
expect(routes).toContain("singleInvoicePdf('pdf')")
expect(routes).toContain("singleAssetImage('image')")
expect(routes).not.toContain('multer')
})
})
Loading
Loading