From c4d0ae1532c4c57ea4df28dfd48325440df42982 Mon Sep 17 00:00:00 2001 From: fiandev Date: Mon, 4 May 2026 21:30:23 +0700 Subject: [PATCH] feat(security): add configurable default HTTP security headers Ref: #47 Co-authored-by: gemini-cli gemini-2.5-flash <218195315+gemini-cli@users.noreply.github.com> --- bun.lock | 10 ++++-- src/gaman.test.ts | 82 +++++++++++++++++++++++++++++++++++++++++++++++ src/gaman.ts | 70 ++++++++++++++++++++++++++++++++++++---- src/types.ts | 20 ++++++++++++ 4 files changed, 173 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index ec9582a..8bcdba4 100644 --- a/bun.lock +++ b/bun.lock @@ -24,11 +24,15 @@ }, "packages/cors": { "name": "@gaman/cors", - "version": "1.0.9", + "version": "1.1.0", + }, + "packages/kame": { + "name": "@gaman/kame", + "version": "0.1.0", }, "packages/static": { "name": "@gaman/static", - "version": "1.0.6", + "version": "1.0.7", }, }, "packages": { @@ -92,6 +96,8 @@ "@gaman/cors": ["@gaman/cors@workspace:packages/cors"], + "@gaman/kame": ["@gaman/kame@workspace:packages/kame"], + "@gaman/michi": ["@gaman/michi@0.1.3", "", {}, "sha512-vNkpTEfUa9F8KkpU1OwuY8mKEYmFcpILirsAD7S31IiEThx9inADI/ASO612miX1K6oGWOTqbNXAO09j5I1Hug=="], "@gaman/static": ["@gaman/static@workspace:packages/static"], diff --git a/src/gaman.test.ts b/src/gaman.test.ts index a903a99..8614c7d 100644 --- a/src/gaman.test.ts +++ b/src/gaman.test.ts @@ -53,3 +53,85 @@ describe('Gaman App Instance', () => { expect(app.michi.find('POST', '/submit')).not.toBeNull(); }); }); + +describe('Gaman default security headers', () => { + it('should store security options', () => { + const app = new Gaman(); + + app.setSecurity({ + xFrameOptions: 'DENY', + noSniff: true, + }); + + // @ts-ignore: testing internal state + expect(app.securityOptions.xFrameOptions).toBe('DENY'); + // @ts-ignore + expect(app.securityOptions.noSniff).toBe(true); + }); + + it('should store full security options', () => { + const app = new Gaman(); + app.setSecurity({ + contentSecurityPolicy: "default-src 'self'", + xFrameOptions: 'SAMEORIGIN', + hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, + noSniff: true, + referrerPolicy: 'strict-origin-when-cross-origin', + xssFilter: true, + crossOriginOpenerPolicy: 'same-origin', + crossOriginEmbedderPolicy: 'require-corp', + crossOriginResourcePolicy: 'same-origin', + cacheControl: 'no-store, private', + xPermittedCrossDomainPolicies: 'none', + xDownloadOptions: 'noopen', + }); + + // @ts-ignore + const opts = app.securityOptions; + expect(opts.contentSecurityPolicy).toBe("default-src 'self'"); + expect(opts.xFrameOptions).toBe('SAMEORIGIN'); + expect(opts.hsts?.maxAge).toBe(31536000); + expect(opts.hsts?.includeSubDomains).toBe(true); + expect(opts.hsts?.preload).toBe(true); + expect(opts.noSniff).toBe(true); + expect(opts.referrerPolicy).toBe('strict-origin-when-cross-origin'); + expect(opts.xssFilter).toBe(true); + expect(opts.crossOriginOpenerPolicy).toBe('same-origin'); + expect(opts.crossOriginEmbedderPolicy).toBe('require-corp'); + expect(opts.crossOriginResourcePolicy).toBe('same-origin'); + expect(opts.cacheControl).toBe('no-store, private'); + expect(opts.xPermittedCrossDomainPolicies).toBe('none'); + expect(opts.xDownloadOptions).toBe('noopen'); + }); + + it('should replace security options when called multiple times', () => { + const app = new Gaman(); + app.setSecurity({ xFrameOptions: 'DENY' }); + app.setSecurity({ noSniff: true, xssFilter: true }); + + // @ts-ignore + expect(app.securityOptions.xFrameOptions).toBeUndefined(); + // @ts-ignore + expect(app.securityOptions.noSniff).toBe(true); + // @ts-ignore + expect(app.securityOptions.xssFilter).toBe(true); + }); + + it('should apply security options from mountServer parameter', () => { + const app = new Gaman(); + app.mountServer({ http: 3431 }, { xFrameOptions: 'DENY', noSniff: true }); + + // @ts-ignore + expect(app.securityOptions.xFrameOptions).toBe('DENY'); + // @ts-ignore + expect(app.securityOptions.noSniff).toBe(true); + }); + + it('should handle mountServer without security parameter', () => { + const app = new Gaman(); + app.mountServer({ http: 3431 }); + + // @ts-ignore + expect(app.securityOptions).toEqual({}); + }); +}); diff --git a/src/gaman.ts b/src/gaman.ts index 407b1a5..21401f3 100644 --- a/src/gaman.ts +++ b/src/gaman.ts @@ -19,6 +19,7 @@ import type { Routes, NextFunction, ResponseData, + SecurityOptions, } from './types'; import type { ExceptionHandler, @@ -31,6 +32,7 @@ export class Gaman { private michi = new Michi(); private globalMiddlewares: MiddlewareHandler[] = []; private globalExceptionHandler: ExceptionHandler | null = null; + private securityOptions: SecurityOptions = {}; /** * @ID @@ -91,6 +93,54 @@ export class Gaman { } finalResponse.headers.set('X-Powered-By', 'GamanJS'); + + const { securityOptions } = this; + + /** + * Default security headers will be applied to every responses. + * This can be overridden by user-defined headers. + */ + + if (securityOptions.contentSecurityPolicy) { + finalResponse.headers.set('Content-Security-Policy', securityOptions.contentSecurityPolicy); + } + if (securityOptions.xFrameOptions) { + finalResponse.headers.set('X-Frame-Options', securityOptions.xFrameOptions); + } + if (securityOptions.hsts) { + const directives = [`max-age=${securityOptions.hsts.maxAge}`]; + if (securityOptions.hsts.includeSubDomains) directives.push('includeSubDomains'); + if (securityOptions.hsts.preload) directives.push('preload'); + finalResponse.headers.set('Strict-Transport-Security', directives.join('; ')); + } + if (securityOptions.noSniff) { + finalResponse.headers.set('X-Content-Type-Options', 'nosniff'); + } + if (securityOptions.referrerPolicy) { + finalResponse.headers.set('Referrer-Policy', securityOptions.referrerPolicy); + } + if (securityOptions.xssFilter) { + finalResponse.headers.set('X-XSS-Protection', '1; mode=block'); + } + if (securityOptions.crossOriginOpenerPolicy) { + finalResponse.headers.set('Cross-Origin-Opener-Policy', securityOptions.crossOriginOpenerPolicy); + } + if (securityOptions.crossOriginEmbedderPolicy) { + finalResponse.headers.set('Cross-Origin-Embedder-Policy', securityOptions.crossOriginEmbedderPolicy); + } + if (securityOptions.crossOriginResourcePolicy) { + finalResponse.headers.set('Cross-Origin-Resource-Policy', securityOptions.crossOriginResourcePolicy); + } + if (securityOptions.cacheControl) { + finalResponse.headers.set('Cache-Control', securityOptions.cacheControl); + } + if (securityOptions.xPermittedCrossDomainPolicies) { + finalResponse.headers.set('X-Permitted-Cross-Domain-Policies', securityOptions.xPermittedCrossDomainPolicies); + } + if (securityOptions.xDownloadOptions) { + finalResponse.headers.set('X-Download-Options', securityOptions.xDownloadOptions); + } + if (ctx && typeof ctx.headers.getSetHeaders === 'function') { for (const [key, value] of ctx.headers.getSetHeaders()) { if (value) { @@ -264,23 +314,29 @@ export class Gaman { } } - public mountServer(config?: GamanServerConfig) { - Logger.log( - `${TextFormat.BOLD}${TextFormat.LIGHT_PURPLE}GamanJS Framework`, - ); + public setSecurity(options: SecurityOptions) { + this.securityOptions = options; + } + + public mountServer(config?: GamanServerConfig, security?: SecurityOptions) { + if (security) { + this.setSecurity(security); + } + + Logger.log(`${TextFormat.BOLD}${TextFormat.LIGHT_PURPLE}GamanJS Framework`); Logger.info( `${TextFormat.ITALIC}A Lean Framework for Enterprise Scalability.`, ); Logger.log(`${TextFormat.GRAY} —————————————————————————————————————— `); if (typeof config?.http !== 'undefined') { - const h = + const httpConfig: HttpServerConfig = typeof config.http === 'number' ? { port: config.http } : (config.http as HttpServerConfig); - const host = h.host || 'localhost'; - const port = h.port || 3431; + const host = httpConfig.host || 'localhost'; + const port = httpConfig.port || 3431; Logger.info( `${TextFormat.LIGHT_BLUE}HTTP${TextFormat.RESET} : Listening at ${TextFormat.LIGHT_GREEN}http://${host}:${port}`, diff --git a/src/types.ts b/src/types.ts index 64f0a57..ed9bea2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -448,3 +448,23 @@ export type RouterBuilder = { router: Router, ) => RouterBuilderWithoutHttpMethods; }; + + +export interface SecurityOptions { + contentSecurityPolicy?: string, + xFrameOptions?: 'DENY' | 'SAMEORIGIN', + hsts?: { + maxAge: number, + includeSubDomains?: boolean, + preload?: boolean + }, + noSniff?: boolean, + referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url', + xssFilter?: boolean, + crossOriginOpenerPolicy?: 'same-origin' | 'same-origin-allow-popups' | 'unsafe-none', + crossOriginEmbedderPolicy?: 'require-corp' | 'credentialless', + crossOriginResourcePolicy?: 'same-origin' | 'same-site' | 'cross-origin', + cacheControl?: string, + xPermittedCrossDomainPolicies?: 'none' | 'master-only' | 'by-content-type' | 'all', + xDownloadOptions?: 'noopen', +} \ No newline at end of file