Skip to content

Commit d6811b2

Browse files
committed
fix(rest): emit the projected export header on an empty result set (#3547)
`GET /data/:object/export` wrote a zero-byte file whenever the query matched no rows — the header was only ever written alongside the first data chunk. The `getReadableFields` projection derives the readable column set from schema + context, so it is known even with zero rows: "export columns don't depend on row content" is only true if it also holds at zero rows. The header is emitted only when the column set is AUTHORITATIVE — the security service's readable projection, or an explicit `?fields=` request (the caller named the columns, so echoing them discloses nothing new). When the header is schema-derived and the projection was unavailable, the export stays headerless as before: the masked-row fallback has no rows to narrow with, and writing the full schema header would name FLS-hidden columns. `header=false` still suppresses the header in every case. Tests: empty result emits the projected header (csv + xlsx); no projection stays headerless; explicit `?fields=` is echoed; `header=false` wins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZ2BGusRo58ZW8FMkDhGTb
1 parent 6ba3788 commit d6811b2

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): export emits the projected header row on an empty result set (#3547)
6+
7+
`GET /data/:object/export` wrote a zero-byte file whenever the query matched no
8+
rows — the header was only ever written alongside the first data chunk. With the
9+
`getReadableFields` column projection the readable column set is derived from
10+
schema + context, so it is known even when no rows come back: an empty CSV/xlsx
11+
export now carries the exact readable header, which also makes it a usable
12+
import template.
13+
14+
The header is emitted only when the column set is AUTHORITATIVE — the security
15+
service's readable projection, or an explicit `?fields=` request. When the header
16+
is schema-derived and the projection was unavailable, the export stays headerless
17+
as before: the masked-row fallback has no rows to narrow with, and writing the
18+
full schema header would name FLS-hidden columns. `header=false` still suppresses
19+
the header in every case.

packages/rest/src/export-integration.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,4 +490,82 @@ describe('export route — FLS column projection via getReadableFields (#3547)',
490490
const header = parseCsv(csv.chunks.join(''))[0];
491491
expect(header).toEqual(['ID', '标题']); // requested columns kept as asked
492492
});
493+
494+
// -------------------------------------------------------------------------
495+
// Empty result sets. "Export columns don't depend on row content" is only
496+
// true if it also holds at ZERO rows — the case the masked-row inference
497+
// (#3498) could never serve, because it had no rows to narrow with.
498+
// -------------------------------------------------------------------------
499+
500+
it('empty result set: still emits the projected header (an exact, row-independent column set)', async () => {
501+
// No rows at all. The service knows the readable columns from schema +
502+
// context, so the export carries the precise header — and an empty export
503+
// becomes a usable import template instead of a zero-byte file.
504+
const { route } = await bootWithSecurity({
505+
getReadableFields: () => ['id', 'done', 'priority', 'due', 'owner'],
506+
tasks: [],
507+
});
508+
const csv = makeRes();
509+
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res);
510+
const rows = parseCsv(csv.chunks.join(''));
511+
expect(rows).toHaveLength(1); // header only, no data rows
512+
expect(rows[0]).toEqual(['ID', '完成', '优先级', '截止', '负责人']);
513+
expect(rows[0]).not.toContain('标题'); // the masked column stays out
514+
});
515+
516+
it('empty result set with NO projection available: stays headerless (never names FLS-hidden columns)', async () => {
517+
// getReadableFields → undefined (schema unresolvable, or no security service
518+
// at all) → the route falls back to masked-row inference, which has no rows.
519+
// Writing the full schema header HERE would leak the names of FLS-hidden
520+
// columns — the very leak #3391 closes — so the empty file stays headerless.
521+
const { route } = await bootWithSecurity({
522+
getReadableFields: () => undefined,
523+
tasks: [],
524+
});
525+
const csv = makeRes();
526+
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res);
527+
expect(csv.chunks.join('')).toBe('');
528+
});
529+
530+
it('empty result set + explicit ?fields=: the requested header is echoed back', async () => {
531+
// An explicit request is authoritative for the same reason the projection is:
532+
// the caller named the columns, so nothing new is disclosed by echoing them.
533+
const { route } = await bootWithSecurity({
534+
getReadableFields: () => ['id'],
535+
tasks: [],
536+
});
537+
const csv = makeRes();
538+
await route.handler(
539+
{ params: { object: 'task' }, query: { format: 'csv', fields: 'id,title' } } as any,
540+
csv.res,
541+
);
542+
expect(parseCsv(csv.chunks.join(''))).toEqual([['ID', '标题']]);
543+
});
544+
545+
it('empty result set + header=false: no header, as asked', async () => {
546+
const { route } = await bootWithSecurity({
547+
getReadableFields: () => ['id', 'done'],
548+
tasks: [],
549+
});
550+
const csv = makeRes();
551+
await route.handler(
552+
{ params: { object: 'task' }, query: { format: 'csv', header: 'false' } } as any,
553+
csv.res,
554+
);
555+
expect(csv.chunks.join('')).toBe('');
556+
});
557+
558+
it('xlsx: an empty result set carries the projected header row', async () => {
559+
const { route } = await bootWithSecurity({
560+
getReadableFields: () => ['id', 'done'],
561+
tasks: [],
562+
});
563+
const { res, getBuffer } = makeBinRes();
564+
await route.handler({ params: { object: 'task' }, query: { format: 'xlsx' } } as any, res);
565+
const wb = new ExcelJS.Workbook();
566+
await wb.xlsx.load(getBuffer() as any);
567+
const ws = wb.worksheets[0];
568+
expect((ws.getRow(1).values as any[]).slice(1)).toEqual(['ID', '完成']);
569+
expect(ws.rowCount).toBe(1); // header only
570+
});
493571
});

packages/rest/src/rest-server.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4219,6 +4219,11 @@ export class RestServer {
42194219
// their option label, booleans to 是/否, dates to YYYY-MM-DD. When the
42204220
// schema is unavailable the raw stored values stream through unchanged.
42214221
//
4222+
// A zero-row result still emits the header row when the column set is
4223+
// authoritative (the security service's readable projection, or an explicit
4224+
// `fields=`), so an empty export doubles as an import template. Without a
4225+
// projection it stays headerless, so FLS-hidden column names never leak.
4226+
//
42224227
// Streams the response so 50k-row exports do not buffer in memory; the
42234228
// xlsx path pipes exceljs' streaming writer straight onto the response.
42244229
// Filename suggests `${objectLabel}-${YYYYMMDD}-${HHMMSS}.${ext}` for
@@ -4481,6 +4486,32 @@ export class RestServer {
44814486
skip += rows.length;
44824487
if (rows.length < take) break;
44834488
}
4489+
// [#3547] Zero rows: still emit the header when the column set is
4490+
// AUTHORITATIVE. "Export columns don't depend on row content" is
4491+
// only true if it also holds at zero rows — and the readable
4492+
// projection above is derived from schema + context, so an empty
4493+
// result has an exact header to write (which also makes an empty
4494+
// export a usable import template). An explicit `?fields=` is
4495+
// authoritative for the same reason: the caller named the columns.
4496+
//
4497+
// Deliberately NOT emitted when the header is schema-derived and
4498+
// the projection was unavailable (`fieldsFromSchema &&
4499+
// !readableProjected`): the masked-row fallback has no rows to
4500+
// narrow with, so writing the full schema header would name
4501+
// FLS-hidden columns — precisely the leak #3391 closes. That path
4502+
// keeps today's headerless empty file.
4503+
if (
4504+
firstChunk && includeHeader && fields && fields.length > 0 &&
4505+
(readableProjected || !fieldsFromSchema)
4506+
) {
4507+
if (format === 'csv') {
4508+
res.write(rowsToCsv(fields, [], true, metaMap));
4509+
} else if (format === 'xlsx') {
4510+
xlsx!.ws.addRow(fields.map((f) => headerLabel(f, metaMap))).commit();
4511+
}
4512+
// json has no header concept — the empty array is already correct.
4513+
}
4514+
44844515
if (format === 'json') {
44854516
res.write(']');
44864517
res.end();

0 commit comments

Comments
 (0)