From fc2abf5b2474fe3773e97889c952eb4257170b68 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 4 Jun 2026 12:55:35 +0200 Subject: [PATCH] [web] fix submit modal in Codespaces (#34137) GitOrigin-RevId: dc057ed736e97265a901b1cf21995c1f391339a5 --- .../Features/Exports/ExportsController.mjs | 39 ++++++++++++++++++- .../src/Features/V1/V1ConversionHelper.mjs | 12 ++++++ .../src/Exports/ExportsController.test.mjs | 8 ++-- 3 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 services/web/app/src/Features/V1/V1ConversionHelper.mjs diff --git a/services/web/app/src/Features/Exports/ExportsController.mjs b/services/web/app/src/Features/Exports/ExportsController.mjs index d5ba10dbbe..994a50f72c 100644 --- a/services/web/app/src/Features/Exports/ExportsController.mjs +++ b/services/web/app/src/Features/Exports/ExportsController.mjs @@ -4,6 +4,10 @@ import SessionManager from '../Authentication/SessionManager.mjs' import logger from '@overleaf/logger' import OError from '@overleaf/o-error' import settings from '@overleaf/settings' +import { fetchStreamWithResponse } from '@overleaf/fetch-utils' +import { isTrustedConversionJobUrl } from '../V1/V1ConversionHelper.mjs' +import { pipeline } from 'node:stream/promises' +import { parseReq, z, zz } from '../../infrastructure/Validation.mjs' async function exportProject(req, res, next) { const { project_id: projectId, brand_variation_id: brandVariationId } = @@ -105,9 +109,21 @@ async function exportStatus(req, res) { return res.json({ export_json: json }) } +const exportDownloadSchema = z.object({ + params: z.object({ + export_id: zz.submissionId(), + type: z.enum(['pdf', 'zip']), + }), + query: z.object({ + token: z.string().optional(), + }), +}) + async function exportDownload(req, res, next) { - const { type, export_id: exportId } = req.params - const { token } = req.query + const { + params: { type, export_id: exportId }, + query: { token }, + } = parseReq(req, exportDownloadSchema) if (!token && settings.exports?.requireToken) { return res.sendStatus(403) } @@ -119,6 +135,25 @@ async function exportDownload(req, res, next) { type, token ) + if ( + settings.exports?.proxyDownload && + // V1 always returns a valid URL here; throw if that assumption is violated. + isTrustedConversionJobUrl(new URL(exportFileUrl)) + ) { + const { stream, response } = await fetchStreamWithResponse(exportFileUrl) + + res.attachment(`${token}.${type}`) + res.setHeader('X-Content-Type-Options', 'nosniff') + + if (response.headers.has('Content-Length')) { + res.setHeader('Content-Length', response.headers.get('Content-Length')) + } + + // Disable buffering in nginx + res.setHeader('X-Accel-Buffering', 'no') + await pipeline(stream, res) + return + } return res.redirect(exportFileUrl) } catch (err) { const info = OError.getFullInfo(err) diff --git a/services/web/app/src/Features/V1/V1ConversionHelper.mjs b/services/web/app/src/Features/V1/V1ConversionHelper.mjs new file mode 100644 index 0000000000..03a6cdee92 --- /dev/null +++ b/services/web/app/src/Features/V1/V1ConversionHelper.mjs @@ -0,0 +1,12 @@ +import Settings from '@overleaf/settings' + +/** + * @param {URL} url + * @return {boolean} + */ +export function isTrustedConversionJobUrl(url) { + if (!Settings.v1ConversionJobAllowedHosts.includes(url.host)) return false + return Settings.v1ConversionJobAllowedPathPrefixes.some(prefix => + url.pathname.startsWith(prefix) + ) +} diff --git a/services/web/test/unit/src/Exports/ExportsController.test.mjs b/services/web/test/unit/src/Exports/ExportsController.test.mjs index 77c25df599..c9a9d71352 100644 --- a/services/web/test/unit/src/Exports/ExportsController.test.mjs +++ b/services/web/test/unit/src/Exports/ExportsController.test.mjs @@ -306,7 +306,7 @@ describe('ExportsController', function () { beforeEach(function (ctx) { ctx.req.params = { project_id: projectId, - export_id: 897, + export_id: '897', type: 'zip', } ctx.handler.fetchDownload = sinon.stub().resolves('https://example.com') @@ -328,7 +328,7 @@ describe('ExportsController', function () { ctx.req.query = { token: 'mock-token' } await ctx.controller.exportDownload(ctx.req, ctx.res) expect(ctx.handler.fetchDownload).to.have.been.calledWith( - 897, + '897', 'zip', 'mock-token' ) @@ -341,7 +341,7 @@ describe('ExportsController', function () { ctx.req.query = {} await ctx.controller.exportDownload(ctx.req, ctx.res) expect(ctx.handler.fetchDownload).to.have.been.calledWith( - 897, + '897', 'zip', undefined ) @@ -359,7 +359,7 @@ describe('ExportsController', function () { ) await ctx.controller.exportDownload(ctx.req, ctx.res, ctx.next) expect(ctx.handler.fetchDownload).to.have.been.calledWith( - 897, + '897', 'zip', 'wrong-token' )