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
84 changes: 83 additions & 1 deletion lib/swagger-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string> {
const headerNames = new Set<string>();
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())
)
);
}
}
}
}
142 changes: 142 additions & 0 deletions test/swagger-module-security-headers.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});