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
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;
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -153,17 +153,19 @@ describe('MailSenderService transporter pooling', () => {
return t as any;
});

// 51 distinct configs: the 51st set() LRU-evicts the first entry before its send runs
// max + 1 distinct configs: the last set() LRU-evicts the first entry before its send runs
const cacheMax = (service as unknown as { transporterCache: { max: number } }).transporterCache
.max;
const results = await Promise.all(
Array.from({ length: 51 }, (_, i) =>
Array.from({ length: cacheMax + 1 }, (_, i) =>
service.sendMailByConfig(
{ to: `user${i}@example.com` },
{ ...smtpConfig, auth: { user: `user-${i}`, pass: 'pass' } }
)
)
);

expect(results).toHaveLength(51);
expect(results).toHaveLength(cacheMax + 1);
expect(transporters[0].sendMail).toHaveBeenCalledTimes(1);
expect(transporters[0].close).toHaveBeenCalledTimes(1);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@teable/openapi';
import { isString } from 'lodash';
import { LRUCache } from 'lru-cache';
import ms from 'ms';
import { I18nService } from 'nestjs-i18n';
import type { Transporter } from 'nodemailer';
import { createTransport } from 'nodemailer';
Expand Down Expand Up @@ -42,8 +43,8 @@ export class MailSenderService implements OnModuleDestroy {
// Connection pool per SMTP config: transporter objects hold live sockets, so this
// must live in process memory (not in the shared cache service)
private readonly transporterCache = new LRUCache<string, IPooledTransporter>({
max: 50,
ttl: 30 * 60 * 1000,
max: 200,
ttl: ms('30m'),
updateAgeOnGet: true,
// Closing a nodemailer pool rejects its queued sends, so busy entries are
// only marked here; the last send's finally closes them once drained
Expand Down Expand Up @@ -153,6 +154,11 @@ export class MailSenderService implements OnModuleDestroy {
const key = this.getTransporterCacheKey(config);
let entry = this.transporterCache.get(key);
if (!entry) {
// Don't log the key: it hashes the password, so even a prefix is an
// offline-testable verifier
this.logger.debug(
`Transporter cache miss (host=${config.host}, size=${this.transporterCache.size})`
);
const created = await this.createTransporter(config);
// A concurrent request may have populated the same key while we created ours;
// reuse theirs so set() does not dispose (close) a transporter that is mid-send
Expand Down
Loading
Loading