From 097d9da99604e5cf3f9ea26aed6534ee640c1825 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Fri, 27 Mar 2026 19:33:42 +0530 Subject: [PATCH] fix(module): filter header params that duplicate security schemes When @Headers("Authorization") is used alongside bearer auth, Swagger generated both a security requirement and a redundant header parameter. Now filters out header parameters whose names match security scheme headers (Authorization for http schemes, custom names for apiKey in header). Closes #3252 --- lib/swagger-module.ts | 84 ++++++++++- test/swagger-module-security-headers.spec.ts | 142 +++++++++++++++++++ 2 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 test/swagger-module-security-headers.spec.ts diff --git a/lib/swagger-module.ts b/lib/swagger-module.ts index 93c41cc5d..4c367a7ea 100644 --- a/lib/swagger-module.ts +++ b/lib/swagger-module.ts @@ -8,6 +8,9 @@ import { SwaggerCustomOptions, SwaggerDocumentOptions } from './interfaces'; +import { + SecuritySchemeObject +} from './interfaces/open-api-spec.interface'; import { MetadataLoader } from './plugin/metadata-loader'; import { SwaggerScanner } from './swagger-scanner'; import { @@ -42,12 +45,16 @@ export class SwaggerModule { document.components ); - return { + const finalDocument: OpenAPIObject = { openapi: '3.0.0', paths: {}, ...config, ...document }; + + this.filterSecuritySchemeHeaders(finalDocument); + + return finalDocument; } public static async loadPluginMetadata( @@ -362,4 +369,79 @@ export class SwaggerModule { SwaggerModule.serveStatic(serveStaticSlashEndingPath, app); } } + + /** + * Collects header names that are implicitly handled by security schemes. + * For example, bearer/basic auth uses the "Authorization" header, + * and apiKey schemes with `in: 'header'` use their configured `name`. + */ + private static getSecuritySchemeHeaderNames( + document: OpenAPIObject + ): Set { + const headerNames = new Set(); + const securitySchemes = document.components?.securitySchemes; + if (!securitySchemes) { + return headerNames; + } + + for (const scheme of Object.values(securitySchemes)) { + if ('$ref' in scheme) { + continue; + } + const securityScheme = scheme as SecuritySchemeObject; + if (securityScheme.type === 'http') { + // HTTP auth schemes (bearer, basic, etc.) use the Authorization header + headerNames.add('authorization'); + } else if ( + securityScheme.type === 'apiKey' && + securityScheme.in === 'header' && + securityScheme.name + ) { + headerNames.add(securityScheme.name.toLowerCase()); + } + } + return headerNames; + } + + /** + * Removes header parameters from operations that are already covered + * by security scheme definitions (e.g., "Authorization" for bearer auth). + */ + private static filterSecuritySchemeHeaders(document: OpenAPIObject): void { + const securityHeaders = this.getSecuritySchemeHeaderNames(document); + if (securityHeaders.size === 0) { + return; + } + + const paths = document.paths; + if (!paths) { + return; + } + + for (const pathItem of Object.values(paths)) { + for (const method of [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace' + ]) { + const operation = pathItem[method]; + if (!operation?.parameters) { + continue; + } + operation.parameters = operation.parameters.filter( + (param: any) => + !( + param.in === 'header' && + typeof param.name === 'string' && + securityHeaders.has(param.name.toLowerCase()) + ) + ); + } + } + } } diff --git a/test/swagger-module-security-headers.spec.ts b/test/swagger-module-security-headers.spec.ts new file mode 100644 index 000000000..b4bc9841e --- /dev/null +++ b/test/swagger-module-security-headers.spec.ts @@ -0,0 +1,142 @@ +import { Controller, Get, Headers } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '../lib'; + +describe('SwaggerModule - Security scheme header filtering', () => { + @Controller('test') + class TestController { + @Get('bearer') + getWithAuthHeader(@Headers('Authorization') auth: string) { + return auth; + } + + @Get('custom-header') + getWithCustomHeader(@Headers('X-Custom') custom: string) { + return custom; + } + } + + let app; + + beforeAll(async () => { + app = await NestFactory.create( + { + module: class {}, + controllers: [TestController] + }, + { logger: false } + ); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should filter out Authorization header parameter when bearer auth is configured', () => { + const config = new DocumentBuilder() + .setTitle('Test') + .setVersion('1.0') + .addBearerAuth() + .addSecurityRequirements('bearer') + .build(); + + const document = SwaggerModule.createDocument(app, config); + const params = document.paths['/test/bearer']?.get?.parameters || []; + + // The Authorization header should NOT appear as a parameter + const authParam = params.find( + (p: any) => p.in === 'header' && p.name === 'Authorization' + ); + expect(authParam).toBeUndefined(); + }); + + it('should filter out Authorization header parameter when basic auth is configured', () => { + const config = new DocumentBuilder() + .setTitle('Test') + .setVersion('1.0') + .addBasicAuth() + .addSecurityRequirements('basic') + .build(); + + const document = SwaggerModule.createDocument(app, config); + const params = document.paths['/test/bearer']?.get?.parameters || []; + + const authParam = params.find( + (p: any) => p.in === 'header' && p.name === 'Authorization' + ); + expect(authParam).toBeUndefined(); + }); + + it('should filter out custom API key header parameter when apiKey scheme uses it', () => { + const config = new DocumentBuilder() + .setTitle('Test') + .setVersion('1.0') + .addApiKey({ type: 'apiKey', in: 'header', name: 'X-Custom' }, 'custom') + .addSecurityRequirements('custom') + .build(); + + const document = SwaggerModule.createDocument(app, config); + const params = + document.paths['/test/custom-header']?.get?.parameters || []; + + const customParam = params.find( + (p: any) => p.in === 'header' && p.name === 'X-Custom' + ); + expect(customParam).toBeUndefined(); + }); + + it('should NOT filter out non-security header parameters', () => { + const config = new DocumentBuilder() + .setTitle('Test') + .setVersion('1.0') + .addBearerAuth() + .addSecurityRequirements('bearer') + .build(); + + const document = SwaggerModule.createDocument(app, config); + const params = + document.paths['/test/custom-header']?.get?.parameters || []; + + // X-Custom header should still appear since it's not a security header + const customParam = params.find( + (p: any) => p.in === 'header' && p.name === 'X-Custom' + ); + expect(customParam).toBeDefined(); + }); + + it('should not filter any headers when no security schemes are configured', () => { + const config = new DocumentBuilder() + .setTitle('Test') + .setVersion('1.0') + .build(); + + const document = SwaggerModule.createDocument(app, config); + const params = document.paths['/test/bearer']?.get?.parameters || []; + + // Authorization should still appear since there are no security schemes + const authParam = params.find( + (p: any) => p.in === 'header' && p.name === 'Authorization' + ); + expect(authParam).toBeDefined(); + }); + + it('should handle case-insensitive header name matching', () => { + const config = new DocumentBuilder() + .setTitle('Test') + .setVersion('1.0') + .addBearerAuth() + .addSecurityRequirements('bearer') + .build(); + + const document = SwaggerModule.createDocument(app, config); + const bearerParams = document.paths['/test/bearer']?.get?.parameters || []; + + // Even though @Headers('Authorization') uses title case, + // it should be filtered since bearer auth uses the Authorization header + const authParam = bearerParams.find( + (p: any) => + p.in === 'header' && p.name.toLowerCase() === 'authorization' + ); + expect(authParam).toBeUndefined(); + }); +});