diff --git a/apps/api/src/modules/misc/misc.controller.spec.ts b/apps/api/src/modules/misc/misc.controller.spec.ts index 02b41f60cf..11543fd441 100644 --- a/apps/api/src/modules/misc/misc.controller.spec.ts +++ b/apps/api/src/modules/misc/misc.controller.spec.ts @@ -4,6 +4,8 @@ import { createMock } from '@golevelup/ts-jest'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { MiscService } from '../misc/misc.service'; +import { ApiKeyService } from '../auth/api-key.service'; +import { PrismaService } from '../common/prisma.service'; describe('MiscController', () => { let controller: MiscController; @@ -11,6 +13,8 @@ describe('MiscController', () => { const configService = createMock(); const jwtService = createMock(); const miscService = createMock(); + const apiKeyService = createMock(); + const prismaService = createMock(); beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -19,6 +23,8 @@ describe('MiscController', () => { { provide: ConfigService, useValue: configService }, { provide: JwtService, useValue: jwtService }, { provide: MiscService, useValue: miscService }, + { provide: ApiKeyService, useValue: apiKeyService }, + { provide: PrismaService, useValue: prismaService }, ], }).compile(); @@ -28,4 +34,35 @@ describe('MiscController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('buildCorsHeaders', () => { + const buildCorsHeaders = (origin?: string) => + (controller as any).buildCorsHeaders(origin) as Record; + + beforeEach(() => { + configService.get.mockReturnValue('https://app.refly.ai, https://refly.ai'); + }); + + it('reflects origin and allows credentials for allowlisted origins', () => { + const headers = buildCorsHeaders('https://app.refly.ai'); + expect(headers['Access-Control-Allow-Origin']).toBe('https://app.refly.ai'); + expect(headers['Access-Control-Allow-Credentials']).toBe('true'); + expect(headers['Vary']).toBe('Origin'); + expect(headers['Cross-Origin-Resource-Policy']).toBe('cross-origin'); + }); + + it('does not reflect non-allowlisted origins', () => { + const headers = buildCorsHeaders('https://evil.example.com'); + expect(headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(headers['Access-Control-Allow-Credentials']).toBeUndefined(); + // no-cors embedding (e.g. ) must keep working + expect(headers['Cross-Origin-Resource-Policy']).toBe('cross-origin'); + }); + + it('omits CORS headers when no Origin header is present', () => { + const headers = buildCorsHeaders(undefined); + expect(headers['Access-Control-Allow-Origin']).toBeUndefined(); + expect(headers['Access-Control-Allow-Credentials']).toBeUndefined(); + }); + }); }); diff --git a/apps/api/src/modules/misc/misc.controller.ts b/apps/api/src/modules/misc/misc.controller.ts index 3ed43b7ed1..cedebd4359 100644 --- a/apps/api/src/modules/misc/misc.controller.ts +++ b/apps/api/src/modules/misc/misc.controller.ts @@ -12,6 +12,7 @@ import { Query, } from '@nestjs/common'; import path from 'node:path'; +import { ConfigService } from '@nestjs/config'; import { JwtAuthGuard } from '../auth/guard/jwt-auth.guard'; import { MiscService } from '../misc/misc.service'; import { @@ -32,7 +33,37 @@ import { checkHttpCache, send304NotModified, applyCacheHeaders } from '../../uti @Controller('v1/misc') export class MiscController { - constructor(private readonly miscService: MiscService) {} + constructor( + private readonly miscService: MiscService, + private readonly configService: ConfigService, + ) {} + + /** + * Build CORS headers for file-serving endpoints. + * Only origins in the ORIGIN allowlist (same source as the global CORS + * config in main.ts) get credentialed access; other origins receive no + * Access-Control-Allow-Origin header, so browsers block cross-origin reads + * of authenticated responses. Cross-Origin-Resource-Policy stays + * 'cross-origin' so legitimate no-cors embedding (e.g. ) keeps working. + */ + private buildCorsHeaders(origin: string | undefined): Record { + const allowedOrigins = (this.configService.get('origin') ?? '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean); + + const headers: Record = { + 'Cross-Origin-Resource-Policy': 'cross-origin', + }; + + if (origin && allowedOrigins.includes(origin)) { + headers['Access-Control-Allow-Origin'] = origin; + headers['Access-Control-Allow-Credentials'] = 'true'; + headers['Vary'] = 'Origin'; + } + + return headers; + } @UseGuards(JwtAuthGuard) @Post('scrape') @@ -104,7 +135,7 @@ export class MiscController { ): Promise { const storageKey = `static/${objectKey}`; const filename = path.basename(objectKey); - const origin = req.headers.origin; + const corsHeaders = this.buildCorsHeaders(req.headers.origin); // First, get only metadata (no file content loaded yet) const { contentType, lastModified } = await this.miscService.getInternalFileMetadata( @@ -120,12 +151,6 @@ export class MiscController { immutable: false, }); - const corsHeaders = { - 'Access-Control-Allow-Origin': origin || '*', - 'Access-Control-Allow-Credentials': 'true', - 'Cross-Origin-Resource-Policy': 'cross-origin', - }; - // If client has valid cache, return 304 Not Modified (without loading file content) if (cacheResult.useCache) { send304NotModified(res, cacheResult, corsHeaders);