diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index a6d58c8a6..42baaa4fb 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -181,10 +181,12 @@ export enum UseCaseType { AGENTS_VALIDATE_TABLE_AI_REQUEST = 'AGENTS_VALIDATE_TABLE_AI_REQUEST', AGENTS_VALIDATE_CONNECTION_EDIT = 'AGENTS_VALIDATE_CONNECTION_EDIT', AGENTS_GET_AI_CONNECTION_CONTEXT = 'AGENTS_GET_AI_CONNECTION_CONTEXT', + AGENTS_GET_AI_CONNECTION_TABLES = 'AGENTS_GET_AI_CONNECTION_TABLES', AGENTS_GET_AI_TABLE_STRUCTURE = 'AGENTS_GET_AI_TABLE_STRUCTURE', AGENTS_EXECUTE_AI_RAW_QUERY = 'AGENTS_EXECUTE_AI_RAW_QUERY', AGENTS_EXECUTE_AI_AGGREGATION_PIPELINE = 'AGENTS_EXECUTE_AI_AGGREGATION_PIPELINE', AGENTS_SCAN_AND_CREATE_SETTINGS = 'AGENTS_SCAN_AND_CREATE_SETTINGS', + AGENTS_GET_COMPANY_SUBSCRIPTION_INFO = 'AGENTS_GET_COMPANY_SUBSCRIPTION_INFO', CREATE_TABLE_FILTERS = 'CREATE_TABLE_FILTERS', FIND_TABLE_FILTERS = 'FIND_TABLE_FILTERS', diff --git a/backend/src/main.ts b/backend/src/main.ts index b2eb84f28..da6aae6cb 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -12,6 +12,7 @@ import { WinstonLogger } from './entities/logging/winston-logger.js'; import { AllExceptionsFilter } from './exceptions/all-exceptions.filter.js'; import { ValidationException } from './exceptions/custom-exceptions/validation-exception.js'; import { Constants } from './helpers/constants/constants.js'; +import { publicCrudCorsMiddleware } from './middlewares/public-crud-cors.middleware.js'; import { appConfig } from './shared/config/app-config.js'; async function bootstrap() { @@ -38,6 +39,10 @@ async function bootstrap() { app.use(helmet()); + // Wildcard CORS for the public table CRUD routes — registered before the global enableCors() + // so it owns these routes (including the OPTIONS preflight) before the global allowlist runs. + app.use(publicCrudCorsMiddleware); + app.enableCors({ origin: [ 'https://app.autoadmin.org', diff --git a/backend/src/microservices/agents-microservice/agents.controller.ts b/backend/src/microservices/agents-microservice/agents.controller.ts index 52b1ee948..36ad33e80 100644 --- a/backend/src/microservices/agents-microservice/agents.controller.ts +++ b/backend/src/microservices/agents-microservice/agents.controller.ts @@ -10,7 +10,9 @@ import { isTest } from '../../helpers/app/is-test.js'; import { SentryInterceptor } from '../../interceptors/sentry.interceptor.js'; import { AiConnectionContextRO, + AiConnectionTablesRO, AiQueryResultRO, + CompanySubscriptionInfoRO, PermissionAllowedRO, ValidatedUserTokenRO, } from './data-structures/agents-responses.ds.js'; @@ -21,11 +23,14 @@ import { GetAiTableStructureDto, } from './dto/agents-ai-data.dtos.js'; import { ValidateConnectionEditDto, ValidateTableAiRequestDto, ValidateUserTokenDto } from './dto/agents-auth.dtos.js'; +import { GetCompanySubscriptionInfoDto } from './dto/agents-company.dtos.js'; import { IExecuteAiAggregationPipeline, IExecuteAiRawQuery, IGetAiConnectionContext, + IGetAiConnectionTables, IGetAiTableStructure, + IGetCompanySubscriptionInfo, IScanAndCreateSettings, IValidateConnectionEdit, IValidateTableAiRequest, @@ -48,6 +53,8 @@ export class AgentsController { private readonly validateConnectionEditUseCase: IValidateConnectionEdit, @Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT) private readonly getAiConnectionContextUseCase: IGetAiConnectionContext, + @Inject(UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES) + private readonly getAiConnectionTablesUseCase: IGetAiConnectionTables, @Inject(UseCaseType.AGENTS_GET_AI_TABLE_STRUCTURE) private readonly getAiTableStructureUseCase: IGetAiTableStructure, @Inject(UseCaseType.AGENTS_EXECUTE_AI_RAW_QUERY) @@ -56,6 +63,8 @@ export class AgentsController { private readonly executeAiAggregationPipelineUseCase: IExecuteAiAggregationPipeline, @Inject(UseCaseType.AGENTS_SCAN_AND_CREATE_SETTINGS) private readonly scanAndCreateSettingsUseCase: IScanAndCreateSettings, + @Inject(UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO) + private readonly getCompanySubscriptionInfoUseCase: IGetCompanySubscriptionInfo, ) {} @ApiOperation({ summary: 'Validate an end-user JWT on behalf of the agents microservice' }) @@ -102,6 +111,21 @@ export class AgentsController { ); } + @ApiOperation({ summary: 'List connection tables the user may read (grounds website feasibility)' }) + @ApiResponse({ status: 201, type: AiConnectionTablesRO }) + @ApiBody({ type: AiDataRequestBaseDto }) + @Timeout(!isTest() ? TimeoutDefaults.EXTENDED : TimeoutDefaults.EXTENDED_TEST) + @Post('/ai/data/:connectionId/tables') + public async getAiConnectionTables( + @SlugUuid('connectionId') connectionId: string, + @Body() body: AiDataRequestBaseDto, + ): Promise { + return await this.getAiConnectionTablesUseCase.execute( + { connectionId, userId: body.userId, masterPassword: body.masterPassword ?? null }, + InTransactionEnum.OFF, + ); + } + @ApiOperation({ summary: 'Get permission-aware table structure for the AI tool loop' }) @ApiResponse({ status: 201, description: 'Table structure with related tables.' }) @ApiBody({ type: GetAiTableStructureDto }) @@ -184,4 +208,14 @@ export class AgentsController { InTransactionEnum.OFF, ); } + + @ApiOperation({ summary: "Read a user's company subscription metadata (agents-core owns all feature policy)" }) + @ApiResponse({ status: 201, type: CompanySubscriptionInfoRO }) + @ApiBody({ type: GetCompanySubscriptionInfoDto }) + @Post('/company/subscription-info') + public async getCompanySubscriptionInfo( + @Body() body: GetCompanySubscriptionInfoDto, + ): Promise { + return await this.getCompanySubscriptionInfoUseCase.execute({ userId: body.userId }, InTransactionEnum.OFF); + } } diff --git a/backend/src/microservices/agents-microservice/agents.module.ts b/backend/src/microservices/agents-microservice/agents.module.ts index 73b7740c6..414a7b33f 100644 --- a/backend/src/microservices/agents-microservice/agents.module.ts +++ b/backend/src/microservices/agents-microservice/agents.module.ts @@ -7,7 +7,9 @@ import { AgentsController } from './agents.controller.js'; import { ExecuteAiAggregationPipelineUseCase } from './use-cases/execute-ai-aggregation-pipeline.use.case.js'; import { ExecuteAiRawQueryUseCase } from './use-cases/execute-ai-raw-query.use.case.js'; import { GetAiConnectionContextUseCase } from './use-cases/get-ai-connection-context.use.case.js'; +import { GetAiConnectionTablesUseCase } from './use-cases/get-ai-connection-tables.use.case.js'; import { GetAiTableStructureUseCase } from './use-cases/get-ai-table-structure.use.case.js'; +import { GetCompanySubscriptionInfoUseCase } from './use-cases/get-company-subscription-info.use.case.js'; import { ScanAndCreateSettingsUseCase } from './use-cases/scan-and-create-settings.use.case.js'; import { ValidateConnectionEditUseCase } from './use-cases/validate-connection-edit.use.case.js'; import { ValidateTableAiRequestUseCase } from './use-cases/validate-table-ai-request.use.case.js'; @@ -36,6 +38,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca provide: UseCaseType.AGENTS_GET_AI_CONNECTION_CONTEXT, useClass: GetAiConnectionContextUseCase, }, + { + provide: UseCaseType.AGENTS_GET_AI_CONNECTION_TABLES, + useClass: GetAiConnectionTablesUseCase, + }, { provide: UseCaseType.AGENTS_GET_AI_TABLE_STRUCTURE, useClass: GetAiTableStructureUseCase, @@ -52,6 +58,10 @@ import { ValidateUserTokenUseCase } from './use-cases/validate-user-token.use.ca provide: UseCaseType.AGENTS_SCAN_AND_CREATE_SETTINGS, useClass: ScanAndCreateSettingsUseCase, }, + { + provide: UseCaseType.AGENTS_GET_COMPANY_SUBSCRIPTION_INFO, + useClass: GetCompanySubscriptionInfoUseCase, + }, ], controllers: [AgentsController], }) diff --git a/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts b/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts index 36bf5072e..c2e282643 100644 --- a/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts +++ b/backend/src/microservices/agents-microservice/data-structures/agents-responses.ds.ts @@ -40,3 +40,22 @@ export class AiQueryResultRO { @ApiProperty() result: unknown; } + +export class AiConnectionTablesRO { + @ApiProperty({ type: [String], description: 'Table names the user is permitted to read on the connection.' }) + tables: Array; +} + +export class CompanySubscriptionInfoRO { + @ApiProperty({ description: 'Whether the backend is running in SaaS mode. When false, no subscription applies.' }) + isSaaS: boolean; + + @ApiPropertyOptional({ nullable: true }) + companyId: string | null; + + @ApiPropertyOptional({ nullable: true, description: 'FREE_PLAN | TEAM_PLAN | ENTERPRISE_PLAN | ANNUAL_* | null' }) + subscriptionLevel: string | null; + + @ApiProperty() + isPaymentMethodAdded: boolean; +} diff --git a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts index cfc4339ee..aae703cb9 100644 --- a/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts +++ b/backend/src/microservices/agents-microservice/data-structures/agents.ds.ts @@ -34,3 +34,7 @@ export class ExecuteAiAggregationPipelineDs extends AiDataRequestDs { export class ScanAndCreateSettingsDs extends AiDataRequestDs { response: Response; } + +export class GetCompanySubscriptionInfoDs { + userId: string; +} diff --git a/backend/src/microservices/agents-microservice/dto/agents-company.dtos.ts b/backend/src/microservices/agents-microservice/dto/agents-company.dtos.ts new file mode 100644 index 000000000..bf0d03c8d --- /dev/null +++ b/backend/src/microservices/agents-microservice/dto/agents-company.dtos.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GetCompanySubscriptionInfoDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + userId: string; +} diff --git a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts index 2e03408e2..851c5ea9d 100644 --- a/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts +++ b/backend/src/microservices/agents-microservice/use-cases/agents-use-cases.interface.ts @@ -4,13 +4,16 @@ import { ExecuteAiAggregationPipelineDs, ExecuteAiRawQueryDs, GetAiTableStructureDs, + GetCompanySubscriptionInfoDs, ScanAndCreateSettingsDs, ValidateConnectionEditDs, ValidateTableAiRequestDs, } from '../data-structures/agents.ds.js'; import { AiConnectionContextRO, + AiConnectionTablesRO, AiQueryResultRO, + CompanySubscriptionInfoRO, PermissionAllowedRO, ValidatedUserTokenRO, } from '../data-structures/agents-responses.ds.js'; @@ -35,6 +38,10 @@ export interface IGetAiTableStructure { execute(inputData: GetAiTableStructureDs, inTransaction: InTransactionEnum): Promise>; } +export interface IGetAiConnectionTables { + execute(inputData: AiDataRequestDs, inTransaction: InTransactionEnum): Promise; +} + export interface IExecuteAiRawQuery { execute(inputData: ExecuteAiRawQueryDs, inTransaction: InTransactionEnum): Promise; } @@ -46,3 +53,10 @@ export interface IExecuteAiAggregationPipeline { export interface IScanAndCreateSettings { execute(inputData: ScanAndCreateSettingsDs, inTransaction: InTransactionEnum): Promise; } + +export interface IGetCompanySubscriptionInfo { + execute( + inputData: GetCompanySubscriptionInfoDs, + inTransaction: InTransactionEnum, + ): Promise; +} diff --git a/backend/src/microservices/agents-microservice/use-cases/get-ai-connection-tables.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/get-ai-connection-tables.use.case.ts new file mode 100644 index 000000000..dba854e90 --- /dev/null +++ b/backend/src/microservices/agents-microservice/use-cases/get-ai-connection-tables.use.case.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { CedarPermissionsService } from '../../../entities/cedar-authorization/cedar-permissions.service.js'; +import { AiDataRequestDs } from '../data-structures/agents.ds.js'; +import { AiConnectionTablesRO } from '../data-structures/agents-responses.ds.js'; +import { setupAiConnection } from '../utils/ai-data-access.helpers.js'; +import { IGetAiConnectionTables } from './agents-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class GetAiConnectionTablesUseCase + extends AbstractUseCase + implements IGetAiConnectionTables +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly cedarPermissions: CedarPermissionsService, + ) { + super(); + } + + protected async implementation(inputData: AiDataRequestDs): Promise { + const { connectionId, userId, masterPassword } = inputData; + + const { foundConnection, dataAccessObject } = await setupAiConnection( + this._dbContext, + connectionId, + masterPassword, + userId, + ); + + const tables = await dataAccessObject.getTablesFromDB(); + const tableNames = tables.map((table) => table.tableName?.trim()).filter((name): name is string => Boolean(name)); + + const readableFlags = await Promise.all( + tableNames.map((tableName) => + this.cedarPermissions.improvedCheckTableRead(userId, foundConnection.id, tableName), + ), + ); + const readableTableNames = tableNames.filter((_name, index) => readableFlags[index]); + + return { tables: readableTableNames }; + } +} diff --git a/backend/src/microservices/agents-microservice/use-cases/get-company-subscription-info.use.case.ts b/backend/src/microservices/agents-microservice/use-cases/get-company-subscription-info.use.case.ts new file mode 100644 index 000000000..4e24ab09a --- /dev/null +++ b/backend/src/microservices/agents-microservice/use-cases/get-company-subscription-info.use.case.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { SubscriptionLevelEnum } from '../../../enums/subscription-level.enum.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { isSaaS } from '../../../helpers/app/is-saas.js'; +import { isTest } from '../../../helpers/app/is-test.js'; +import { SaasCompanyGatewayService } from '../../gateways/saas-gateway.ts/saas-company-gateway.service.js'; +import { GetCompanySubscriptionInfoDs } from '../data-structures/agents.ds.js'; +import { CompanySubscriptionInfoRO } from '../data-structures/agents-responses.ds.js'; +import { IGetCompanySubscriptionInfo } from './agents-use-cases.interface.js'; + +/** + * Thin metadata provider for the agents microservice: resolves a user's company subscription level + * via the saas gateway. The agents service (agents-core) owns all website-generation policy + * (model tier, hosting caps, quota enforcement) and only reads this subscription metadata from here. + */ +@Injectable({ scope: Scope.REQUEST }) +export class GetCompanySubscriptionInfoUseCase + extends AbstractUseCase + implements IGetCompanySubscriptionInfo +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + private readonly saasCompanyGatewayService: SaasCompanyGatewayService, + ) { + super(); + } + + protected async implementation(inputData: GetCompanySubscriptionInfoDs): Promise { + const { userId } = inputData; + + // Self-hosted / non-SaaS / test runs have no subscription concept. + if (!isSaaS() || isTest()) { + return { isSaaS: false, companyId: null, subscriptionLevel: null, isPaymentMethodAdded: false }; + } + + const company = await this._dbContext.companyInfoRepository.findCompanyInfoByUserId(userId); + if (!company) { + throw new NotFoundException(Messages.COMPANY_NOT_FOUND); + } + + const companyInfo = await this.saasCompanyGatewayService.getCompanyInfo(company.id); + return { + isSaaS: true, + companyId: company.id, + subscriptionLevel: companyInfo?.subscriptionLevel ?? SubscriptionLevelEnum.FREE_PLAN, + isPaymentMethodAdded: companyInfo?.is_payment_method_added ?? false, + }; + } +} diff --git a/backend/src/middlewares/public-crud-cors.middleware.ts b/backend/src/middlewares/public-crud-cors.middleware.ts new file mode 100644 index 000000000..3f27a351a --- /dev/null +++ b/backend/src/middlewares/public-crud-cors.middleware.ts @@ -0,0 +1,50 @@ +import { NextFunction, Request, Response } from 'express'; + +const PUBLIC_CRUD_ROUTE_REGEX = /\/table\/crud(\/|$)/; + +// `scheme://host[:port]` (or the literal "null" origin). Deliberately strict: any value that does +// not look like a real origin is dropped rather than reflected. +const VALID_ORIGIN_REGEX = /^(null|[a-z][a-z0-9+.-]*:\/\/[a-z0-9.-]+(:\d+)?)$/i; + +// The charset allowed in an HTTP header field-name list (what a browser sends in +// Access-Control-Request-Headers). Excludes CR/LF and anything outside the token grammar. +const VALID_HEADER_LIST_REGEX = /^[a-z0-9,\- ]+$/i; + +const DEFAULT_ALLOWED_HEADERS = 'Content-Type, Authorization, x-api-key, masterpwd'; + +/** + * Wildcard CORS for the public table CRUD routes (TablePureCrudOperationsController). + * + * These endpoints support public / api-key access and may be called from any origin. + * A literal `Access-Control-Allow-Origin: *` cannot be combined with credentials, so we reflect + * the request's Origin back instead — this allows any origin while still permitting cookie / + * credentialed requests. Must be registered before the global enableCors() so it owns these + * routes (including answering the OPTIONS preflight) before the global allowlist runs. + * + * Reflected request values (Origin, Access-Control-Request-Headers) are validated against strict + * allowlists before being echoed back, so no untrusted input reaches a response header + * (defense-in-depth against header injection / CWE-113). + */ +export function publicCrudCorsMiddleware(req: Request, res: Response, next: NextFunction): void { + if (PUBLIC_CRUD_ROUTE_REGEX.test(req.path)) { + const requestOrigin = req.headers.origin; + if (requestOrigin && VALID_ORIGIN_REGEX.test(requestOrigin)) { + const requestedHeaders = req.headers['access-control-request-headers']; + const allowedHeaders = + typeof requestedHeaders === 'string' && VALID_HEADER_LIST_REGEX.test(requestedHeaders) + ? requestedHeaders + : DEFAULT_ALLOWED_HEADERS; + + res.header('Access-Control-Allow-Origin', requestOrigin); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Vary', 'Origin'); + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); + res.header('Access-Control-Allow-Headers', allowedHeaders); + } + if (req.method === 'OPTIONS') { + res.sendStatus(204); + return; + } + } + next(); +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts index a881016ae..9bc9aa723 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts @@ -12,6 +12,7 @@ import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { publicCrudCorsMiddleware } from '../../../src/middlewares/public-crud-cors.middleware.js'; import { DatabaseModule } from '../../../src/shared/database/database.module.js'; import { DatabaseService } from '../../../src/shared/database/database.service.js'; import { MockFactory } from '../../mock.factory.js'; @@ -41,6 +42,7 @@ test.before(async () => { _testUtils = moduleFixture.get(TestUtils); app.use(cookieParser()); + app.use(publicCrudCorsMiddleware); app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); app.useGlobalPipes( new ValidationPipe({ @@ -674,3 +676,106 @@ test.serial(`${currentTest} an invalid/expired JWT cookie is rejected, never tre t.not(res.status, 200); t.is(Object.hasOwn(JSON.parse(res.text), 'rows'), false); }); + +currentTest = 'CORS for /table/crud routes'; + +test.serial( + `${currentTest} OPTIONS preflight from an arbitrary origin is answered with 204 and the origin reflected`, + async (t) => { + const arbitraryOrigin = 'https://some-third-party-app.example.com'; + + const res = await request(app.getHttpServer()) + .options(`/table/crud/${faker.string.uuid()}?tableName=whatever`) + .set('Origin', arbitraryOrigin) + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'content-type, x-api-key'); + + // Preflight is short-circuited by our middleware before any guard / global allowlist runs. + t.is(res.status, 204); + // `*` cannot be combined with credentials, so the origin is reflected back instead. + t.is(res.headers['access-control-allow-origin'], arbitraryOrigin); + t.is(res.headers['access-control-allow-credentials'], 'true'); + t.is(res.headers['vary'], 'Origin'); + t.is(res.headers['access-control-allow-methods'], 'GET,PUT,POST,DELETE'); + // Requested headers are echoed back so the browser allows them. + t.is(res.headers['access-control-allow-headers'], 'content-type, x-api-key'); + }, +); + +test.serial( + `${currentTest} a real (authenticated) request carries the reflected origin and credentials headers`, + async (t) => { + const { token, connectionId, testTableName } = await createConnectionAndTable(); + const arbitraryOrigin = 'https://another-origin.example.org'; + + const res = await request(app.getHttpServer()) + .post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`) + .send({}) + .set('Origin', arbitraryOrigin) + .set('Cookie', token) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + t.is(res.status, 200); + t.is(res.headers['access-control-allow-origin'], arbitraryOrigin); + t.is(res.headers['access-control-allow-credentials'], 'true'); + }, +); + +test.serial(`${currentTest} when no Origin header is present, no CORS headers are added`, async (t) => { + const res = await request(app.getHttpServer()) + .options(`/table/crud/${faker.string.uuid()}?tableName=whatever`) + .set('Access-Control-Request-Method', 'POST'); + + // Still a preflight (OPTIONS on a /table/crud route) so it is short-circuited... + t.is(res.status, 204); + // ...but with no Origin to reflect, we must not emit an allow-origin header. + t.is(res.headers['access-control-allow-origin'], undefined); + t.is(res.headers['access-control-allow-credentials'], undefined); +}); + +test.serial(`${currentTest} non /table/crud routes are not affected by the wildcard middleware`, async (t) => { + const arbitraryOrigin = 'https://some-third-party-app.example.com'; + + const res = await request(app.getHttpServer()) + .options('/connection') + .set('Origin', arbitraryOrigin) + .set('Access-Control-Request-Method', 'GET'); + + // The middleware only reflects the origin for /table/crud routes; everything else is left to the + // global allowlist (which is not configured in this test app), so no wildcard reflection happens. + t.not(res.headers['access-control-allow-origin'], arbitraryOrigin); +}); + +test.serial(`${currentTest} a malformed Origin is not reflected back (header-injection guard)`, async (t) => { + const malformedOrigin = 'https://evil.example.com/path with spaces'; + + const res = await request(app.getHttpServer()) + .options(`/table/crud/${faker.string.uuid()}?tableName=whatever`) + .set('Origin', malformedOrigin) + .set('Access-Control-Request-Method', 'POST'); + + // Still answered as a preflight, but a value that does not match a valid origin is dropped, not echoed. + t.is(res.status, 204); + t.is(res.headers['access-control-allow-origin'], undefined); + t.is(res.headers['access-control-allow-credentials'], undefined); +}); + +test.serial( + `${currentTest} a malformed Access-Control-Request-Headers falls back to the static allowlist`, + async (t) => { + const arbitraryOrigin = 'https://some-third-party-app.example.com'; + + const res = await request(app.getHttpServer()) + .options(`/table/crud/${faker.string.uuid()}?tableName=whatever`) + .set('Origin', arbitraryOrigin) + .set('Access-Control-Request-Method', 'POST') + // Contains characters outside the header-list token grammar. + .set('Access-Control-Request-Headers', 'content-type; injected: value'); + + t.is(res.status, 204); + // Origin is valid so it is reflected, but the unsafe requested-headers value is replaced. + t.is(res.headers['access-control-allow-origin'], arbitraryOrigin); + t.is(res.headers['access-control-allow-headers'], 'Content-Type, Authorization, x-api-key, masterpwd'); + }, +);