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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import { findFilteringFieldsUtil, parseFilteringFieldsFromBodyData } from '../..
import { findOrderingFieldUtil } from '../../utils/find-ordering-field.util.js';
import { isHexString } from '../../utils/is-hex-string.js';
import { processRowsUtil } from '../../utils/process-found-rows-util.js';
import {
assertSomeColumnReadable,
readableTableStructure,
restrictTableSettingsToReadableColumns,
} from '../../utils/restrict-query-to-readable-columns.util.js';
import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js';
import { PureFoundRowsResponseDs } from '../application/data-structures/pure-found-rows-response.ds.js';
import { PureGetRowsDs } from '../application/data-structures/pure-get-rows.ds.js';
Expand Down Expand Up @@ -66,25 +71,42 @@ export class PureGetRowsFromTableUseCase

const tableStructure = await dao.getTableStructure(tableName, userEmail);

// Column-level read permissions must bound the whole query, not just the response (plan 13
// P0-3): resolved here, BEFORE the filters/ordering are parsed, so a withheld column can be
// neither filtered, searched, ordered by nor selected. Anonymous callers (no userId — the
// public branch of QueryTableGuard) are evaluated against the connection's public policy.
const allColumnNames = tableStructure.map((column) => column.column_name);
const readableColumns = userId
? await this.cedarPermissions.getReadableColumns(userId, connectionId, tableName, allColumnNames)
: await this.cedarPermissions.getReadableColumnsForPublic(connectionId, tableName, allColumnNames);
assertSomeColumnReadable(readableColumns);
const queryableStructure = readableTableStructure(tableStructure, readableColumns);

const filteringFields: Array<FilteringFieldsDs> = isObjectEmpty(filters)
? findFilteringFieldsUtil(query, tableStructure)
: parseFilteringFieldsFromBodyData(filters ?? {}, tableStructure);
const orderingField = findOrderingFieldUtil(query, tableStructure, tableSettings ?? ({} as TableSettingsEntity));
? findFilteringFieldsUtil(query, queryableStructure)
: parseFilteringFieldsFromBodyData(filters ?? {}, queryableStructure);
const orderingField = findOrderingFieldUtil(
query,
queryableStructure,
tableSettings ?? ({} as TableSettingsEntity),
);

const builtTableSettings = buildDAOsTableSettingsDs(buildCommonTableSettingsInput(tableSettings), null);
restrictTableSettingsToReadableColumns(builtTableSettings, readableColumns, allColumnNames);
if (orderingField) {
builtTableSettings.ordering_field = orderingField.field;
builtTableSettings.ordering = orderingField.value;
}

if (
isHexString(searchingFieldValue) &&
(tableStructure.some((field) => isBinary(field.data_type)) ||
(queryableStructure.some((field) => isBinary(field.data_type)) ||
connection.type === ConnectionTypesEnum.mongodb ||
connection.type === ConnectionTypesEnum.agent_mongodb)
) {
searchingFieldValue = hexToBinary(searchingFieldValue) as unknown as string;
builtTableSettings.search_fields = tableStructure
// Readable columns only — a binary search must not reach a withheld column either.
builtTableSettings.search_fields = queryableStructure
.filter((field) => isBinary(field.data_type))
.map((field) => field.column_name);
if (connection.type === ConnectionTypesEnum.mongodb || connection.type === ConnectionTypesEnum.agent_mongodb) {
Expand All @@ -111,10 +133,8 @@ export class PureGetRowsFromTableUseCase

rows = processRowsUtil(rows, tableWidgets, tableCustomFields);

const allColumnNames = tableStructure.map((column) => column.column_name);
const readableColumns = userId
? await this.cedarPermissions.getReadableColumns(userId, connectionId, tableName, allColumnNames)
: await this.cedarPermissions.getReadableColumnsForPublic(connectionId, tableName, allColumnNames);
// Defense in depth on top of the query-level restriction above: a widget/custom field or a DAO
// that ignores excluded_fields must not put a withheld column into the response.
if (!isAllColumnsReadable(readableColumns, allColumnNames)) {
rows.data = filterRowsByReadableColumns(rows.data, readableColumns);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isAllColumnsReadable,
} from '../../utils/filter-columns-by-read-permission.util.js';
import { removePasswordsFromRowsUtil } from '../../utils/remove-password-from-row.util.js';
import { assertSomeColumnReadable } from '../../utils/restrict-query-to-readable-columns.util.js';
import { getUserEmailForAgent, validateConnection } from '../../utils/validate-connection.util.js';
import { PureCrudRowResponseDs } from '../application/data-structures/pure-crud-row-response.ds.js';
import { PureReadRowDs } from '../application/data-structures/pure-read-row.ds.js';
Expand Down Expand Up @@ -88,6 +89,9 @@ export class PureReadRowFromTableUseCase
const readableColumns = userId
? await this.cedarPermissions.getReadableColumns(userId, connectionId, tableName, allColumnNames)
: await this.cedarPermissions.getReadableColumnsForPublic(connectionId, tableName, allColumnNames);
// Fail closed (plan 13 P0-3): no readable column ⇒ 403, not a 200 carrying an empty object —
// the 200-vs-400 outcome of the primary-key lookup is itself a row-existence signal.
assertSomeColumnReadable(readableColumns);
Comment on lines 90 to +94
if (!isAllColumnsReadable(readableColumns, allColumnNames)) {
rowData = filterRowByReadableColumns(rowData, readableColumns);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import { filterRowByReadableColumns, isAllColumnsReadable } from '../utils/filte
import { findFilteringFieldsUtil, parseFilteringFieldsFromBodyData } from '../utils/find-filtering-fields.util.js';
import { findOrderingFieldUtil } from '../utils/find-ordering-field.util.js';
import { isHexString } from '../utils/is-hex-string.js';
import {
assertSomeColumnReadable,
readableTableStructure,
restrictTableSettingsToReadableColumns,
} from '../utils/restrict-query-to-readable-columns.util.js';
import { getUserEmailForAgent, validateConnection } from '../utils/validate-connection.util.js';
import { IExportCSVFromTable } from './table-use-cases.interface.js';

Expand Down Expand Up @@ -72,16 +77,31 @@ export class ExportCSVFromTableUseCase
);
}

// Column-level read permissions bound the query, not just the exported rows (plan 13
// P0-3): a filter/search/ordering on a withheld column would otherwise reveal its
// values through which rows come back. This route requires a userId (`@UserId()`, no
// anonymous branch), so only the authenticated evaluation is needed here.
const allColumnNames = tableStructure.map((column) => column.column_name);
const readableColumns = await this.cedarPermissions.getReadableColumns(
userId,
connectionId,
tableName,
allColumnNames,
);
assertSomeColumnReadable(readableColumns);
const queryableStructure = readableTableStructure(tableStructure, readableColumns);

const filteringFields: Array<FilteringFieldsDs> = isObjectEmpty(filters)
? findFilteringFieldsUtil(query, tableStructure)
: parseFilteringFieldsFromBodyData(filters ?? {}, tableStructure);
? findFilteringFieldsUtil(query, queryableStructure)
: parseFilteringFieldsFromBodyData(filters ?? {}, queryableStructure);

const orderingField = findOrderingFieldUtil(query, tableStructure, tableSettings);
const orderingField = findOrderingFieldUtil(query, queryableStructure, tableSettings);

const builtDAOsTableSettings = buildDAOsTableSettingsDs(
buildCommonTableSettingsInput(tableSettings),
personalTableSettings,
);
restrictTableSettingsToReadableColumns(builtDAOsTableSettings, readableColumns, allColumnNames);

if (orderingField) {
builtDAOsTableSettings.ordering_field = orderingField.field;
Expand All @@ -90,7 +110,8 @@ export class ExportCSVFromTableUseCase

if (isHexString(searchingFieldValue)) {
searchingFieldValue = hexToBinary(searchingFieldValue) as any;
tableSettings.search_fields = tableStructure
// Readable columns only — a binary search must not reach a withheld column either.
tableSettings.search_fields = queryableStructure
.filter((field) => isBinary(field.data_type))
.map((field) => field.column_name);
Comment on lines 111 to 116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the DAO settings object for binary searches.

dao.getTableRowsStream receives builtDAOsTableSettings at Line 121. Line 114 updates tableSettings.search_fields after builtDAOsTableSettings was built. The binary-only search-field restriction therefore has no effect.

Set builtDAOsTableSettings.search_fields instead. Add a CSV regression test with configured text search_fields and a binary-column hex search.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/entities/table/use-cases/export-csv-from-table.use.case.ts`
around lines 111 - 116, Update the binary-search branch in the table export flow
to assign the filtered binary column names to
builtDAOsTableSettings.search_fields, which is the settings object passed to
dao.getTableRowsStream, instead of tableSettings.search_fields. Add a CSV
regression test covering configured text search_fields combined with a
hexadecimal search on a binary column.

}
Comment on lines 111 to 117
Expand All @@ -106,15 +127,7 @@ export class ExportCSVFromTableUseCase

operationResult = OperationResultStatusEnum.successfully;

// Column-level read permission (the ColumnRead half of table:read): drop columns the
// user may not read from the exported rows.
const allColumnNames = tableStructure.map((column) => column.column_name);
const readableColumns = await this.cedarPermissions.getReadableColumns(
userId,
connectionId,
tableName,
allColumnNames,
);
// Response-side projection, on top of the query-level restriction above (defense in depth).
const restrictColumns = !isAllColumnsReadable(readableColumns, allColumnNames);

//todo: rework as streams when node oracle driver will support it correctly
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ForbiddenException } from '@nestjs/common';
import { TableSettingsDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-settings.ds.js';
import { TableStructureDS } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/data-structures/table-structure.ds.js';
import { Messages } from '../../../exceptions/text/messages.js';

// Column-level read permissions must bound the QUERY, not just the response (plan 13 P0-3).
//
// Stripping non-readable columns from the returned rows leaves the withheld values reachable
// indirectly: a filter or search on a hidden column still runs in SQL, and `pagination.total`
// (matched-row count) then answers "does this column start with X?" one character at a time —
// enough to extract a password hash from a table the caller may legitimately query. Ordering by a
// hidden column leaks the same way through the row order across pages.
//
// These helpers make the readable set the INPUT to filtering, search, ordering and the select list:
//
// 1. `readableTableStructure` — parse filters and the ordering field against this instead of the
// full structure, so a filter naming a withheld column is ignored exactly like one naming a
// column that does not exist (no existence oracle, and dropping a filter only ever widens the
// result set, never narrows it to something the caller could not already see).
// 2. `restrictTableSettingsToReadableColumns` — adds every withheld column to `excluded_fields`
// (which `findAvailableFields` honours in every DAO, so it bounds the `select()` list and the
// default search-field set) and intersects an explicit `search_fields` list with the readable
// set.
// 3. `assertSomeColumnReadable` — fails CLOSED on an empty readable set. Without it, excluding
// every column makes the MySQL/MSSQL DAOs fall back to `select('*')`, and the caller still
// gets a real `pagination.total` to run the oracle against.
//
// The post-query projection (`filterRowsByReadableColumns`) stays in place on top of this as
// defense in depth. `universal-backend` enforces the same rule for the generated-site runtime
// (`resolvePublicReadableColumns`, plan 13 Step 0) — keep the two in step.

// No readable column at all ⇒ 403, for authenticated and public callers alike. A caller who may
// query a table but read none of its columns can still mine it through filters, so an empty
// projection is not a safe answer.
export function assertSomeColumnReadable(readableColumns: Set<string>): void {
if (readableColumns.size === 0) {
throw new ForbiddenException(Messages.DONT_HAVE_PERMISSIONS);
}
}

// The table structure reduced to the readable columns — the parsing input for filters/ordering.
export function readableTableStructure(
tableStructure: Array<TableStructureDS>,
readableColumns: Set<string>,
): Array<TableStructureDS> {
return tableStructure.filter((column) => readableColumns.has(column.column_name));
}

// Bounds the DAO's own column handling (select list, default and explicit search fields) to the
// readable set. Mutates the settings object the caller is about to hand to the DAO.
export function restrictTableSettingsToReadableColumns(
settings: TableSettingsDS,
readableColumns: Set<string>,
allColumnNames: Array<string>,
): void {
const withheld = allColumnNames.filter((columnName) => !readableColumns.has(columnName));
if (withheld.length === 0) {
return;
}
settings.excluded_fields = [...new Set([...(settings.excluded_fields ?? []), ...withheld])];
// An explicit search-field list may name withheld columns; intersect it. Emptying it is safe —
// the DAOs then fall back to the available (now readable-only) fields.
if (settings.search_fields && settings.search_fields.length > 0) {
settings.search_fields = settings.search_fields.filter((field) => readableColumns.has(field));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,118 @@ test.serial(`${currentTest} refuses a policy that is not a JSON object (400)`, a
const stringPolicy = await siteRuntimePolicyRequest(connectionId, { userId, policy: 'not-an-object' });
t.is(stringPolicy.status, 400);
});

// --- plan 13 P0-3: a public column whitelist must bound the QUERY, not just the response ---
//
// `readableColumns` used to be applied only as a projection over rows the query had already
// returned, so an anonymous caller could still filter or search on a WITHHELD column and read its
// values back out of which rows came back (`pagination.total` as a per-character oracle over a
// password hash). These tests pin the fix on the core's anonymous `/table/crud` branch: the
// readable set now feeds filtering, search and the select list. (universal-backend enforces the
// same rule for the generated-site runtime — plan 13 Step 0.)
//
// The seeded table has 42 rows; `testTableColumnName` holds the value 'Vasia' in exactly 3 of them
// and `testTableSecondColumnName` holds a unique email per row. Assertions deliberately use a
// filter/search that matches a NON-EMPTY subset when applied, because the MySQL DAO's
// `getRowsCount` treats a filtered count of 0 as falsy and falls back to the whole-table count —
// so "total === 0" is not a usable signal here, while "total === 3 vs 42" is.

function anonymousCrudRowsRequest(
connectionId: string,
tableName: string,
body: Record<string, unknown> = {},
extraQuery = '',
): request.Test {
return request(app.getHttpServer())
.post(`/table/crud/rows/${connectionId}?tableName=${tableName}&page=1&perPage=10${extraQuery}`)
.send(body)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
}

currentTest = 'POST /table/crud/rows/:connectionId (anonymous, public Cedar policy)';

test.serial(`${currentTest} returns only the publicly readable columns`, async (t) => {
const { userId, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } =
await createConnectionAndTable();
await grantRequest(connectionId, {
userId,
tables: [{ tableName: testTableName, readableColumns: [testTableColumnName] }],
});

const response = await anonymousCrudRowsRequest(connectionId, testTableName);
t.is(response.status, 200);
const { rows } = JSON.parse(response.text);
t.is(rows.length, 10);
t.deepEqual(Object.keys(rows[0]), [testTableColumnName]);
t.false(Object.keys(rows[0]).includes(testTableSecondColumnName));
t.false(Object.keys(rows[0]).includes('id'));
});

test.serial(`${currentTest} ignores a filter on a withheld column (kills the pagination oracle)`, async (t) => {
const { userId, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } =
await createConnectionAndTable();
// Grant the EMAIL column only — the name column (3 rows of 'Vasia') is withheld.
await grantRequest(connectionId, {
userId,
tables: [{ tableName: testTableName, readableColumns: [testTableSecondColumnName] }],
});

// Body-filter form: before the fix this ran in SQL and answered "3 rows match 'Vasia'" about a
// column the caller may not read. Now it is ignored exactly like a filter on a column that does
// not exist, so the result set stays the unfiltered one.
const bodyFiltered = await anonymousCrudRowsRequest(connectionId, testTableName, {
filters: { [testTableColumnName]: { eq: 'Vasia' } },
});
t.is(bodyFiltered.status, 200);
const bodyFilteredRO = JSON.parse(bodyFiltered.text);
t.is(bodyFilteredRO.pagination.total, 42);
t.is(bodyFilteredRO.rows.length, 10);
t.deepEqual(Object.keys(bodyFilteredRO.rows[0]), [testTableSecondColumnName]);

// Query-string filter form is bounded the same way.
const queryFiltered = await anonymousCrudRowsRequest(
connectionId,
testTableName,
{},
`&f_${testTableColumnName}__eq=Vasia`,
);
t.is(queryFiltered.status, 200);
t.is(JSON.parse(queryFiltered.text).pagination.total, 42);
});

test.serial(`${currentTest} never searches a withheld column`, async (t) => {
const { userId, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } =
await createConnectionAndTable();
await grantRequest(connectionId, {
userId,
tables: [{ tableName: testTableName, readableColumns: [testTableSecondColumnName] }],
});

// 'Vasia' exists only in the withheld column: the search must match nothing rather than
// returning those 3 rows (before the fix, search ILIKEd every column of the table).
const response = await anonymousCrudRowsRequest(connectionId, testTableName, {}, '&search=Vasia');
t.is(response.status, 200);
const body = JSON.parse(response.text);
t.is(body.rows.length, 0);
t.not(body.pagination.total, 3);
});

test.serial(`${currentTest} still applies a filter and search on a readable column`, async (t) => {
const { userId, connectionId, testTableName, testTableColumnName } = await createConnectionAndTable();
await grantRequest(connectionId, {
userId,
tables: [{ tableName: testTableName, readableColumns: [testTableColumnName] }],
});

const filtered = await anonymousCrudRowsRequest(connectionId, testTableName, {
filters: { [testTableColumnName]: { eq: 'Vasia' } },
});
t.is(filtered.status, 200);
t.is(JSON.parse(filtered.text).pagination.total, 3);
t.is(JSON.parse(filtered.text).rows.length, 3);

const searched = await anonymousCrudRowsRequest(connectionId, testTableName, {}, '&search=Vasia');
t.is(searched.status, 200);
t.is(JSON.parse(searched.text).rows.length, 3);
});
Loading
Loading