Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"test": "run-s test-unit test-e2e",
"test-unit:watch": "vitest --watch",
"test-unit": "vitest run --silent --bail 1",
"test-unit-shard": "pnpm test-unit ${VITEST_SHARD:+--shard=$VITEST_SHARD}",
"test-unit-cover": "pnpm test-unit --coverage ${VITEST_SHARD:+--shard=$VITEST_SHARD}",
"dual-db-cutover-validate": "node ./scripts/validate-dual-db-cutover.mjs",
"pre-test-e2e": "cross-env NODE_ENV=test pnpm -F @teable/db-main-prisma prisma-db-seed -- --e2e",
Expand Down
3 changes: 3 additions & 0 deletions apps/nestjs-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export const appModules = {

return {
connection: redis,
// Tests give short-lived app instances their own queue namespace so
// they don't consume each other's jobs; unset means the bull default.
prefix: process.env.BACKEND_QUEUE_PREFIX,
};
},
inject: [ConfigService],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/* eslint-disable @typescript-eslint/naming-convention */
/**
* Regression guard for GHSA-7r23-c67v-m5c5:
* SQL injection via record filter value on multi-value string fields.
*
* The `is` / `isNot` / `contains` / `doesNotContain` handlers of
* MultipleStringCellValueFilterAdapter previously interpolated the raw filter
* value into whereRaw(), so a single quote closed the SQL string literal and
* injected arbitrary SQL. The fix binds the jsonpath as a parameter and escapes
* the value for the jsonpath string literal.
*
* This test proves the value is now a bound parameter (not inlined SQL) and
* that the rendered SQL cannot be broken out of by the injection payload.
*/
import {
CellValueType,
DbFieldType,
DriverClient,
FieldType,
MultipleSelectFieldCore,
SelectFieldCore,
SingleLineTextFieldCore,
contains,
doesNotContain,
is,
isNot,
} from '@teable/core';
import type { FieldCore, IFilter } from '@teable/core';
import knex from 'knex';
import type { IDbProvider } from '../../db.provider.interface';
import { FilterQueryPostgres } from '../postgres/filter-query.postgres';

const knexBuilder = knex({ client: 'pg' });
const dbProviderStub = { driver: DriverClient.Pg } as unknown as IDbProvider;

// Payload that used to close the jsonpath literal, then the SQL literal, then
// inject a tautology and comment out the trailing syntax.
const INJECTION = 'zzz") \' OR 1=1 --';

function build(field: FieldCore, filter: IFilter) {
const qb = knexBuilder('main_table as main');
new FilterQueryPostgres(qb, { [field.id]: field }, filter, undefined, dbProviderStub, {
selectionMap: new Map([[field.id, `"main"."${field.dbFieldName}"`]]),
}).appendQueryBuilder();
// toSQL() keeps parameters as bindings instead of inlining them.
return qb.toSQL();
}

// A multi-value String field whose dbFieldType is Text β€” the shape that routes
// to MultipleStringCellValueFilterAdapter.
function createMultiTextField(): SingleLineTextFieldCore {
const field = new SingleLineTextFieldCore();
field.id = 'fld_multitext';
field.name = 'fld_multitext';
field.dbFieldName = 'multitext_col';
field.type = FieldType.SingleLineText;
field.options = SingleLineTextFieldCore.defaultOptions();
field.cellValueType = CellValueType.String;
field.isMultipleCellValue = true;
field.isLookup = true;
field.dbFieldType = DbFieldType.Text;
return field;
}

function createMultiSelectField(): MultipleSelectFieldCore {
const field = new MultipleSelectFieldCore();
field.id = 'fld_ms';
field.name = 'fld_ms';
field.dbFieldName = 'ms_col';
field.type = FieldType.MultipleSelect;
field.options = SelectFieldCore.defaultOptions() as never;
field.cellValueType = CellValueType.String;
field.isMultipleCellValue = true;
field.isLookup = false;
field.updateDbFieldType();
return field;
}

describe('GHSA-7r23: multi-value string filter is no longer injectable', () => {
const field = createMultiTextField();

const OPERATORS = [
{ name: 'is', op: is.value },
{ name: 'isNot', op: isNot.value },
{ name: 'contains', op: contains.value },
{ name: 'doesNotContain', op: doesNotContain.value },
];

it.each(OPERATORS)('binds the value for `$name` instead of inlining it', ({ op }) => {
const { sql, bindings } = build(field, {
conjunction: 'and',
filterSet: [{ fieldId: field.id, operator: op, value: INJECTION }],
});

// The value is passed as a bound parameter β€” the raw payload never appears
// in the SQL text, so it cannot break out of the statement.
expect(sql).not.toContain('OR 1=1');
// Rendered as a jsonpath containment predicate with a bound placeholder.
expect(sql).toContain('::jsonb @');
expect(sql.endsWith('?)')).toBe(true);

// The payload lives inside a jsonpath binding, quoted as a string literal.
const jsonPathBinding = bindings.find(
(b): b is string => typeof b === 'string' && b.includes('$[*]')
);
expect(jsonPathBinding).toBeDefined();
expect(jsonPathBinding).toContain('OR 1=1'); // present, but as data in the binding
});

it('multi-select fields still route to the safe json adapter', () => {
const ms = createMultiSelectField();
expect(ms.dbFieldType).toBe(DbFieldType.Json);
const { sql } = build(ms, {
conjunction: 'and',
filterSet: [{ fieldId: ms.id, operator: contains.value, value: INJECTION }],
});
expect(sql).not.toContain('OR 1=1');
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { IFilterOperator, ILiteralValue } from '@teable/core';
import type { Knex } from 'knex';
import { escapeJsonbRegex } from '../../../../../utils/postgres-regex-escape';
import {
escapeJsonPathRegexLiteral,
escapeJsonPathStringLiteral,
} from '../../../../../utils/postgres-regex-escape';
import type { IDbProvider } from '../../../../db.provider.interface';
import { CellValueFilterPostgres } from '../cell-value-filter.postgres';

Expand All @@ -12,7 +15,10 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterPostgre
_dbProvider: IDbProvider
): Knex.QueryBuilder {
this.ensureLiteralValue(value, _operator);
builderClient.whereRaw(`${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ == "${value}")'`);
// Bind the jsonpath as a parameter; never concatenate the raw value into the
// SQL string (a single quote would otherwise break out and inject SQL).
const jsonPath = `$[*] ? (@ == "${escapeJsonPathStringLiteral(String(value))}")`;
builderClient.whereRaw(`${this.tableColumnRef}::jsonb @\\? ?`, [jsonPath]);
return builderClient;
}

Expand All @@ -22,9 +28,8 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterPostgre
value: ILiteralValue,
_dbProvider: IDbProvider
): Knex.QueryBuilder {
builderClient.whereRaw(
`NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? '$[*] \\? (@ == "${value}")'`
);
const jsonPath = `$[*] ? (@ == "${escapeJsonPathStringLiteral(String(value))}")`;
builderClient.whereRaw(`NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? ?`, [jsonPath]);
return builderClient;
}

Expand All @@ -34,11 +39,9 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterPostgre
value: ILiteralValue,
_dbProvider: IDbProvider
): Knex.QueryBuilder {
const escapedValue = escapeJsonbRegex(String(value));
this.ensureLiteralValue(value, _operator);
builderClient.whereRaw(
`${this.tableColumnRef}::jsonb @\\? '$[*] \\? (@ like_regex "${escapedValue}" flag "i")'`
);
const jsonPath = `$[*] ? (@ like_regex "${escapeJsonPathRegexLiteral(String(value))}" flag "i")`;
builderClient.whereRaw(`${this.tableColumnRef}::jsonb @\\? ?`, [jsonPath]);
return builderClient;
}

Expand All @@ -48,11 +51,9 @@ export class MultipleStringCellValueFilterAdapter extends CellValueFilterPostgre
value: ILiteralValue,
_dbProvider: IDbProvider
): Knex.QueryBuilder {
const escapedValue = escapeJsonbRegex(String(value));
this.ensureLiteralValue(value, _operator);
builderClient.whereRaw(
`NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? '$[*] \\? (@ like_regex "${escapedValue}" flag "i")'`
);
const jsonPath = `$[*] ? (@ like_regex "${escapeJsonPathRegexLiteral(String(value))}" flag "i")`;
builderClient.whereRaw(`NOT COALESCE(${this.tableColumnRef}, '[]')::jsonb @\\? ?`, [jsonPath]);
return builderClient;
}
}
14 changes: 12 additions & 2 deletions apps/nestjs-backend/src/event-emitter/listeners/trash.listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,24 @@ export class TrashListener {

if (!deletedTime) return;

await this.prismaService.trash.create({
data: {
// Delete events can fire more than once for the same resource (e.g. soft delete
// followed by permanent delete), so recording the trash entry must be idempotent.
await this.prismaService.trash.upsert({
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention
resourceType_resourceId: { resourceType, resourceId },
},
create: {
resourceId,
resourceType,
parentId,
deletedTime,
deletedBy: user?.id as string,
},
update: {
deletedTime,
deletedBy: user?.id as string,
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ export class AttachmentsService {
if (contentLength > MAX_FILE_SIZE) {
this.throwFileSizeExceeded(MAX_FILE_SIZE);
}
const hash = presignedParams.hash;
const dir = StorageAdapter.getDir(type);
const bucket = StorageAdapter.getBucket(type);
const res = await this.storageAdapter.presigned(bucket, dir, {
Expand All @@ -150,7 +149,7 @@ export class AttachmentsService {
const { path, token } = res;
await this.cacheService.set(
`attachment:signature:${token}`,
{ path, bucket, hash },
{ path, bucket },
signatureRo.expiresIn ?? second(this.storageConfig.tokenExpireIn)
);
return res;
Expand Down
56 changes: 56 additions & 0 deletions apps/nestjs-backend/src/features/auth/permission.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,62 @@ describe('PermissionService', () => {
});
});

describe('getAccessToken (GHSA-c57x: OAuth scope escalation)', () => {
it('does NOT add base|read_all to an OAuth token that did not consent to it', async () => {
// A user consented only to table|read for this OAuth client.
prismaServiceMock.accessToken.findFirstOrThrow.mockResolvedValue({
scopes: JSON.stringify(['table|read'] satisfies Action[]),
spaceIds: null,
baseIds: null,
clientId: 'cltoauthclient00', // IdPrefix.OAuthClient
userId: 'usrxxxxxxxx',
hasFullAccess: null,
} as any);
// OAuth collaborator resolution goes through txClient().collaborator.
prismaServiceMock.txClient.mockReturnValue(prismaServiceMock as any);
prismaServiceMock.collaborator.findMany.mockResolvedValue([]);

const result = await service.getAccessToken('actxxxxxxxx');

// The token must not gain read access it was never approved for.
expect(result.scopes).not.toContain('base|read_all');
expect(result.scopes).toEqual(['table|read']);
});

it('preserves base|read_all for an OAuth token that DID consent to it', async () => {
prismaServiceMock.accessToken.findFirstOrThrow.mockResolvedValue({
scopes: JSON.stringify(['table|read', 'base|read_all'] satisfies Action[]),
spaceIds: null,
baseIds: null,
clientId: 'cltoauthclient00',
userId: 'usrxxxxxxxx',
hasFullAccess: null,
} as any);
prismaServiceMock.txClient.mockReturnValue(prismaServiceMock as any);
prismaServiceMock.collaborator.findMany.mockResolvedValue([]);

const result = await service.getAccessToken('actxxxxxxxx');

expect(result.scopes).toContain('base|read_all');
});

it('does NOT add base|read_all to a regular (non-OAuth) PAT', async () => {
prismaServiceMock.accessToken.findFirstOrThrow.mockResolvedValue({
scopes: JSON.stringify(['table|read'] satisfies Action[]),
spaceIds: null,
baseIds: null,
clientId: null, // regular personal access token
userId: 'usrxxxxxxxx',
hasFullAccess: null,
} as any);

const result = await service.getAccessToken('actxxxxxxxx');

expect(result.scopes).not.toContain('base|read_all');
expect(result.scopes).toEqual(['table|read']);
});
});

describe('getPermissions', () => {
it('should return permissions for a user', async () => {
const resourceId = 'bsexxxxxx';
Expand Down
5 changes: 4 additions & 1 deletion apps/nestjs-backend/src/features/auth/permission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,11 @@ export class PermissionService {
if (clientId && clientId.startsWith(IdPrefix.OAuthClient)) {
const { spaceIds: spaceIdsByOAuth, baseIds: baseIdsByOAuth } =
await this.getOAuthAccessBy(userId);
// Only expose base|read_all when the user actually consented to it.
// Previously it was concatenated unconditionally, granting third-party
// OAuth apps broader read access than the scopes they were approved for.
return {
scopes: scopes.concat('base|read_all'),
scopes,
spaceIds: spaceIdsByOAuth,
baseIds: baseIdsByOAuth,
};
Expand Down
8 changes: 5 additions & 3 deletions apps/nestjs-backend/src/features/base/base.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,6 @@ export class BaseService {
lastModifiedBy: userId,
},
});

return base;
} catch (error) {
await this.prismaService.base.update({
where: { id: base.id },
Expand All @@ -433,6 +431,9 @@ export class BaseService {
});
throw error;
}

await this.markBaseVisited(base.id, spaceId);
return base;
}

async updateBase(baseId: string, updateBaseRo: IUpdateBaseRo) {
Expand Down Expand Up @@ -655,7 +656,7 @@ export class BaseService {
},
})
.catch((error) => {
this.logger.warn(`Failed to seed last-visit for duplicated base ${baseId}: ${error}`);
this.logger.warn(`Failed to seed last-visit for base ${baseId}: ${error}`);
});
}

Expand Down Expand Up @@ -816,6 +817,7 @@ export class BaseService {
templateId,
fromBaseId,
});
await this.markBaseVisited(result.id, spaceId);
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,7 @@ export class FieldSupplementService {
const mergedLookupOptions =
newLookupOptions && oldLookupOptions
? { ...oldLookupOptions, ...newLookupOptions }
: (newLookupOptions ?? oldLookupOptions);
: newLookupOptions ?? oldLookupOptions;

return this.prepareLookupField(tableId, {
...oldFieldVo,
Expand Down Expand Up @@ -1743,11 +1743,7 @@ export class FieldSupplementService {
Boolean(oldFieldVo.isLookup) &&
fieldRo.lookupOptions !== undefined);
if (isLookupField && hasMajorChange) {
return this.prepareUpdateLookupField(
tableId,
{ ...fieldRo, isLookup: true },
oldFieldVo
);
return this.prepareUpdateLookupField(tableId, { ...fieldRo, isLookup: true }, oldFieldVo);
}

switch (fieldRo.type) {
Expand Down
Loading
Loading