fix(server): unicode-safe content-disposition for gpx, ics, feed and wallet downloads (#2165)

This commit is contained in:
Maurice
2026-09-01 14:41:54 +02:00
committed by Maurice
parent 4704bf5903
commit c59425947a
18 changed files with 285 additions and 6 deletions
+3 -1
View File
@@ -689,7 +689,9 @@ export class CalendarService {
const timezones = new Map<string, string>();
for (const [zone, yyyymmdd] of usedZones) timezones.set(zone, buildVTimezone(zone, yyyymmdd));
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_');
// \w + space/tab, not \s: JS \s admits U+3000 and friends — codepoints
// Node's header validation refuses, so they 500'd the export (#2165).
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w \t.-]/g, '_');
return {
calName: esc(trip.title || 'TREK Trip'),
filename: `${safeFilename}.ics`,
@@ -0,0 +1,35 @@
/**
* Content-Disposition values per RFC 6266. Node validates header values against
* Latin-1 and throws ERR_INVALID_CHAR on any codepoint above 0xFF, so quoting a
* trip title like 沖縄 4泊5日 straight into the header 500'd every export
* (#2165). The quoted filename is folded to printable ASCII here, and when that
* folding loses anything the real name rides along RFC 5987-encoded as
* filename*, which browsers prefer. The result is printable ASCII by
* construction: setHeader cannot throw on it, and CR/LF or quotes in a name
* cannot break out of the quoted-string. A name that is already plain ASCII
* produces byte-for-byte the header the call sites used to build by hand.
*/
export function contentDisposition(filename: string, type: 'attachment' | 'inline'): string {
const name = filename
// Control characters (tab included) have no business in a filename on
// either side of the header, and an unpaired surrogate — a name cut
// mid-emoji — would make encodeURIComponent throw below.
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
if (ascii === filename) return `${type}; filename="${filename}"`;
// A name the folding erased entirely still needs a usable quoted fallback;
// the extension survives, the readable name is in filename*.
const dot = ascii.lastIndexOf('.');
const stem = dot > 0 ? ascii.slice(0, dot) : ascii;
const fallback = /[0-9A-Za-z]/.test(stem) ? ascii : `download${dot > 0 ? ascii.slice(dot) : ''}`;
// RFC 5987 attr-char: encodeURIComponent is close but leaves ' ( ) * bare.
const encoded = encodeURIComponent(name).replace(
/['()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { readEnv } from '../../app-config';
import { contentDisposition } from '../common/content-disposition';
import { FeedsService } from './feeds.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -48,7 +49,7 @@ export class FeedsPublicController {
return;
}
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `inline; filename="${result.filename}"`);
res.setHeader('Content-Disposition', contentDisposition(result.filename, 'inline'));
res.setHeader('Cache-Control', 'no-cache, no-store');
res.setHeader('X-Published-TTL', 'PT1H');
res.send(result.ics);
@@ -2,6 +2,7 @@ import { Controller, Get, HttpException, Param, Req, Res } from '@nestjs/common'
import type { Request, Response } from 'express';
import path from 'path';
import { FilesService } from './files.service';
import { contentDisposition } from '../common/content-disposition';
import { Public } from '../auth/public.decorator';
import { StorageService } from '../storage/storage.service';
@@ -68,7 +69,7 @@ export class FilesDownloadController {
name,
res,
walletMime
? { contentType: walletMime, disposition: `inline; filename="${path.basename(file.original_name || name)}"` }
? { contentType: walletMime, disposition: contentDisposition(path.basename(file.original_name || name), 'inline') }
: undefined,
);
}
Binary file not shown.
+2 -1
View File
@@ -18,6 +18,7 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { contentDisposition } from '../common/content-disposition';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { memoryStorage } from 'multer';
import { hexColorSchema, placeImageUrlSchema, placeWebsiteSchema } from '@trek/shared';
@@ -214,7 +215,7 @@ export class PlacesController {
throw new HttpException({ error: 'Nothing to export' }, 404);
}
res.setHeader('Content-Type', 'application/gpx+xml; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.setHeader('Content-Disposition', contentDisposition(result.filename, 'attachment'));
res.send(result.gpx);
}
+2 -1
View File
@@ -18,6 +18,7 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { contentDisposition } from '../common/content-disposition';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import type { Request, Response } from 'express';
import type { Options } from 'multer';
@@ -273,7 +274,7 @@ export class TripsController {
try {
const { ics, filename } = this.calendar.exportICS(id);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment'));
res.send(ics);
} catch (e: unknown) {
if (e instanceof NotFoundError) throw new HttpException({ error: e.message }, 404);
+18
View File
@@ -250,6 +250,24 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
expect(buildTripCalendar).toHaveBeenCalledWith(5);
});
// The @Public feed quoted cal.filename straight into Content-Disposition, so
// a non-ASCII calendar name was an unauthenticated 500 (#2165).
it('public trip feed: a non-ASCII filename folds to an ASCII header with filename*', async () => {
const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1));
const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
// ASCII parity first: the default sample calendar keeps the exact legacy header.
const plain = await request(server).get(`/api/feed/trip/${token}.ics`);
expect(plain.headers['content-disposition']).toBe('inline; filename="sample.ics"');
buildTripCalendar.mockImplementation(() => ({ ...sampleCalendar(), filename: '沖縄カレンダー.ics' }));
const res = await request(server).get(`/api/feed/trip/${token}.ics`);
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toBe(
'inline; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC.ics',
);
});
it('public trip feed: 404 for an unknown token', async () => {
const res = await request(server).get('/api/feed/trip/00000000-0000-0000-0000-000000000000.ics');
expect(res.status).toBe(404);
+15
View File
@@ -334,6 +334,21 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
expect(res.text).toContain('<name>1. Warm up</name>');
});
// The reported repro (#2165): a Japanese trip title crashed setHeader with
// ERR_INVALID_CHAR before the first body byte. Now the header folds to
// ASCII and carries the real name RFC 5987-encoded.
it('a non-ASCII trip title exports 200 with a filename* header instead of a 500 (#2165)', async () => {
db.prepare('INSERT INTO trips (id, title) VALUES (5, ?)').run('沖縄 4泊5日');
db.prepare('INSERT INTO places (id, trip_id, name, lat, lng) VALUES (1, 5, ?, 26.217, 127.719)').run('首里城');
const res = await request(server).get('/api/trips/5/places/export.gpx').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toBe(
'attachment; filename="__-4_5_.gpx"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84-4%E6%B3%8A5%E6%97%A5.gpx',
);
expect(res.text).toContain('<name>首里城</name>');
});
it('narrows the document to the requested parts', async () => {
seedTrip();
const res = await request(server)
+13 -1
View File
@@ -71,7 +71,7 @@ const { db } = vi.hoisted(() => {
status TEXT DEFAULT 'pending', reservation_time TEXT, reservation_end_time TEXT, location TEXT,
confirmation_number TEXT, notes TEXT, url TEXT, accommodation_id TEXT, metadata TEXT,
needs_review INTEGER DEFAULT 0, day_plan_position REAL, external_source TEXT, sync_enabled INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
ingest_state TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);');
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
image_url TEXT, address TEXT, lat REAL, lng REAL, category_id INTEGER, description TEXT,
@@ -233,6 +233,18 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
});
});
// Real CalendarService against the temp db: a title carrying U+3000 slipped
// through the old \s keep-class into setHeader and 500'd the export (#2165).
it('200 export.ics with a header-safe filename for a title full of ideographic whitespace', async () => {
const tripId = Number(db.prepare('INSERT INTO trips (user_id, title, start_date, end_date) VALUES (1, ?, ?, ?)')
.run('沖縄 4泊5日', '2026-05-01', '2026-05-05').lastInsertRowid);
const res = await request(server).get(`/api/trips/${tripId}/export.ics`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('text/calendar');
expect(res.headers['content-disposition']).toBe('attachment; filename="___4_5_.ics"');
expect(res.text).toContain('BEGIN:VCALENDAR');
});
it('200 bundle for an accessible trip (real member list)', async () => {
const tripId = seedTrip('B');
const res = await request(server).get(`/api/trips/${tripId}/bundle`).set('Cookie', sessionCookie(1));
@@ -998,6 +998,21 @@ describe('Tool: export_trip_gpx', () => {
});
});
// Same gpxFilename as the REST route (#2165): the title's script survives in
// the filename now — the header folding is the controller's job, not ours.
it('keeps a non-ASCII trip title in the filename', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日' });
createPlace(testDb, trip.id, { name: '首里城', lat: 26.217, lng: 127.719 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_gpx', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as { gpx: string; filename: string };
expect(data.filename).toBe('沖縄-4泊5日.gpx');
expect(data.gpx).toContain('首里城');
});
});
it('writes each planned day as a route through its stops in order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Weekend' });
@@ -491,6 +491,18 @@ describe('Tool: export_trip_ics', () => {
expect(result.isError).toBe(true);
});
});
// Same calendar filename as the REST route (#2165): U+3000 used to slip
// through the \s keep-class and crash the header there; it folds to _ now.
it('folds header-hostile whitespace out of the filename', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日', start_date: '2025-06-01', end_date: '2025-06-05' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as { filename: string };
expect(data.filename).toBe('___4_5_.ics');
});
});
});
// ---------------------------------------------------------------------------
@@ -233,6 +233,22 @@ describe('exportICS', () => {
expect(filename).toMatch(/My.Trip.2025\.ics/);
});
// JS \s admitted U+3000 (and \v/\f) into the filename, and Node's header
// validation then threw ERR_INVALID_CHAR on the export route (#2165).
it('TRIP-SVC-008b: ideographic whitespace in the title folds to _ in the filename', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日' });
expect(svc.exportICS(trip.id).filename).toBe('___4_5_.ics');
});
it('TRIP-SVC-008c: vertical tab and form feed fold too', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'A\vB\fC' });
expect(svc.exportICS(trip.id).filename).toBe('A_B_C.ics');
});
it('TRIP-SVC-009: reservation with end time includes DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { contentDisposition } from '../../../src/nest/common/content-disposition';
// Node refuses header values with codepoints above 0xFF (ERR_INVALID_CHAR), so
// the builder must hand back printable ASCII no matter what the name holds —
// and stay byte-identical to the old hand-built header for plain ASCII names.
describe('contentDisposition (#2165)', () => {
it('keeps a plain ASCII name byte-identical to the hand-built header', () => {
expect(contentDisposition('Alpine-week.gpx', 'attachment')).toBe('attachment; filename="Alpine-week.gpx"');
expect(contentDisposition('all-trips.ics', 'inline')).toBe('inline; filename="all-trips.ics"');
// Spaces are fine inside a quoted-string; no reason to grow a filename*.
expect(contentDisposition('My Trip 2025.ics', 'attachment')).toBe('attachment; filename="My Trip 2025.ics"');
expect(contentDisposition('', 'attachment')).toBe('attachment; filename=""');
});
it('adds filename* only when the quoted fallback loses something', () => {
expect(contentDisposition('沖縄-4泊5日.gpx', 'attachment')).toBe(
'attachment; filename="__-4_5_.gpx"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84-4%E6%B3%8A5%E6%97%A5.gpx',
);
});
it('folds an accent in both its composed and combining forms', () => {
expect(contentDisposition('Café.gpx', 'attachment')).toBe(
'attachment; filename="Caf_.gpx"; filename*=UTF-8\'\'Caf%C3%A9.gpx',
);
// NFKD output: 'e' + U+0301 — the combining mark alone is already > 0xFF.
expect(contentDisposition('Café.gpx', 'attachment')).toBe(
'attachment; filename="Cafe_.gpx"; filename*=UTF-8\'\'Cafe%CC%81.gpx',
);
});
it('neutralizes CR/LF and quotes — nothing escapes the quoted-string', () => {
expect(contentDisposition('evil\r\nContent-Length: 0"; x="y.gpx', 'attachment')).toBe(
'attachment; filename="evil Content-Length: 0_; x=_y.gpx"; filename*=UTF-8\'\'evil%20%20Content-Length%3A%200%22%3B%20x%3D%22y.gpx',
);
});
it('folds a tab to a space instead of riding it into the header', () => {
expect(contentDisposition('a\tb.ics', 'attachment')).toBe(
'attachment; filename="a b.ics"; filename*=UTF-8\'\'a%20b.ics',
);
});
it('catches the JS-\\s stragglers like U+3000', () => {
expect(contentDisposition('a b.ics', 'inline')).toBe(
'inline; filename="a_b.ics"; filename*=UTF-8\'\'a%E3%80%80b.ics',
);
});
it("escapes the RFC 5987 stragglers encodeURIComponent leaves bare: ' ( ) *", () => {
expect(contentDisposition("日(1)'*.gpx", 'attachment')).toBe(
'attachment; filename="_(1)\'*.gpx"; filename*=UTF-8\'\'%E6%97%A5%281%29%27%2A.gpx',
);
});
it('falls back to download when the folding erased everything readable, keeping the extension', () => {
expect(contentDisposition('日本語.gpx', 'attachment')).toBe(
'attachment; filename="download.gpx"; filename*=UTF-8\'\'%E6%97%A5%E6%9C%AC%E8%AA%9E.gpx',
);
expect(contentDisposition('日本語', 'attachment')).toBe(
'attachment; filename="download"; filename*=UTF-8\'\'%E6%97%A5%E6%9C%AC%E8%AA%9E',
);
});
it('carries an emoji through filename* and survives a name cut mid-emoji', () => {
expect(contentDisposition('\u{1F5FE}.gpx', 'attachment')).toBe(
'attachment; filename="download.gpx"; filename*=UTF-8\'\'%F0%9F%97%BE.gpx',
);
// A lone surrogate would make encodeURIComponent throw "URI malformed" —
// exactly the crash class this helper exists to end.
expect(contentDisposition('\uD83D.gpx', 'attachment')).toBe(
'attachment; filename="download.gpx"; filename*=UTF-8\'\'%EF%BF%BD.gpx',
);
});
it('always yields a value Node accepts: printable ASCII, whatever comes in', () => {
const nasty = [
'沖縄 4泊5日.gpx',
'a
b
c.ics',
'"\\.pkpass',
'\x00\x1f\x7f.gpx',
'\u{1F9ED}'.repeat(40),
'plain.txt',
'',
];
for (const name of nasty) {
expect(contentDisposition(name, 'attachment')).toMatch(/^[\x20-\x7e]+$/);
expect(contentDisposition(name, 'inline')).toMatch(/^[\x20-\x7e]+$/);
}
});
});
@@ -279,6 +279,19 @@ describe('FilesDownloadController', () => {
disposition: 'inline; filename="pass.pkpass"',
});
});
// Uploads store original_name raw, so a Japanese boarding pass used to hand
// setHeader a value it throws on (#2165) — storage.service.ts documents the
// throw as reachable; now the header is ASCII with the real name in filename*.
it('folds a non-ASCII original name to ASCII + filename* instead of crashing setHeader (#2165)', async () => {
const storage = dstor();
const s = dsvc({ getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: '搭乗券.pkpass' }) });
await new FilesDownloadController(s, storage).download(req, res, '5', '9');
expect(storage.sendToResponse).toHaveBeenCalledWith('files', 'pass.pkpass', res, {
contentType: 'application/vnd.apple.pkpass',
disposition: 'inline; filename="download.pkpass"; filename*=UTF-8\'\'%E6%90%AD%E4%B9%97%E5%88%B8.pkpass',
});
});
});
describe('PhotosController', () => {
@@ -174,4 +174,17 @@ describe('gpxFilename', () => {
expect(gpxFilename('///')).toBe('trip.gpx');
expect(gpxFilename('')).toBe('trip.gpx');
});
// Header safety moved into contentDisposition() (#2165) — the filename may
// keep the title's own script instead of tripping setHeader's Latin-1 check.
it('keeps a non-ASCII title as typed', () => {
expect(gpxFilename('沖縄 4泊5日')).toBe('沖縄-4泊5日.gpx');
// No NFKD any more: the composed é stays composed instead of decaying into
// 'e' + a combining mark that only looked stripped.
expect(gpxFilename('Café')).toBe('Café.gpx');
});
it('trims on codepoint boundaries, never through the middle of an emoji', () => {
expect(gpxFilename('\u{1F5FE}'.repeat(70))).toBe('\u{1F5FE}'.repeat(60) + '.gpx');
});
});
@@ -147,6 +147,29 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
});
});
describe('GET /export.gpx (#2165)', () => {
const makeRes = () => ({ setHeader: vi.fn(), send: vi.fn() });
const exportQuery = {} as never;
it('an ASCII filename keeps the exact legacy header', () => {
const res = makeRes();
const s = svc({ exportGpx: vi.fn().mockReturnValue({ gpx: '<gpx/>', filename: 'Alpine-week.gpx' }) } as Partial<PlacesService>);
new PlacesController(s, new RuntimeEnvService(), storageStub).exportGpx(user, '5', exportQuery, res as never);
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="Alpine-week.gpx"');
expect(res.send).toHaveBeenCalledWith('<gpx/>');
});
it('a non-ASCII filename no longer reaches setHeader raw: ASCII fallback + filename*', () => {
const res = makeRes();
const s = svc({ exportGpx: vi.fn().mockReturnValue({ gpx: '<gpx/>', filename: '沖縄-4泊5日.gpx' }) } as Partial<PlacesService>);
new PlacesController(s, new RuntimeEnvService(), storageStub).exportGpx(user, '5', exportQuery, res as never);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="__-4_5_.gpx"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84-4%E6%B3%8A5%E6%97%A5.gpx',
);
});
});
describe('POST /import/google-list + naver-list', () => {
// The legacy 'URL is required' 400 is gone: placeImportListRequestSchema
// pins `url`, so the ZodValidationPipe rejects a urlless body before the
@@ -386,6 +386,16 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR');
});
it('folds a non-ASCII filename into an ASCII header with filename* instead of crashing setHeader (#2165)', () => {
const res = { setHeader: vi.fn(), send: vi.fn() };
const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: '沖縄.ics' }) };
tc(svc(), cal).exportIcs(user, '9', res as never);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84.ics',
);
});
it('maps a NotFoundError from the export to 404 and re-throws others', () => {
const nf = { exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) };
expect(thrown(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } });