diff --git a/packages/core/src/error/http.ts b/packages/core/src/error/http.ts index 58850eeb7e5a..4112932a29d3 100644 --- a/packages/core/src/error/http.ts +++ b/packages/core/src/error/http.ts @@ -87,6 +87,14 @@ export class ForbiddenError extends MidwayHttpError { } } +/** + * 405 http error, Means that the server can only generate an answer which the client doesn't accept. + */ +export class MethodNotAllowedError extends MidwayHttpError { + constructor(resOrMessage?: ResOrMessage) { + super(resOrMessage, HttpStatus.METHOD_NOT_ALLOWED); + } +} /** * 406 http error, Means that the server can only generate an answer which the client doesn't accept. */ @@ -197,6 +205,7 @@ export const httpError = { UnauthorizedError, NotFoundError, ForbiddenError, + MethodNotAllowedError, NotAcceptableError, RequestTimeoutError, ConflictError, diff --git a/packages/security/package.json b/packages/security/package.json index 7adce5a3a735..3754d2eeef0d 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -22,8 +22,10 @@ }, "license": "MIT", "dependencies": { + "@midwayjs/cookies": "^1.0.2", "csrf": "3.1.0", "escape-html": "1.0.3", + "methods": "1.1.2", "nanoid": "3.3.6", "picomatch": "2.3.1", "platform": "1.3.6", diff --git a/packages/security/src/config/config.default.ts b/packages/security/src/config/config.default.ts index 6d1a409e893d..f1b89927d3df 100644 --- a/packages/security/src/config/config.default.ts +++ b/packages/security/src/config/config.default.ts @@ -33,4 +33,11 @@ export const security: Partial = { enable: true, value: '1; mode=block', }, + referrerPolicy: { + enable: false, + value: 'no-referrer-when-downgrade', + }, + methodnoallow: { + enable: false, + }, }; diff --git a/packages/security/src/configuration.ts b/packages/security/src/configuration.ts index 7f8b9e32dee5..5d5a0999aa4f 100644 --- a/packages/security/src/configuration.ts +++ b/packages/security/src/configuration.ts @@ -10,10 +10,13 @@ import { SecurityOptions } from './interface'; import { XFrameMiddleware } from './middleware/xframe.middleware'; import { HSTSMiddleware } from './middleware/hsts.middleware'; import { NoOpenMiddleware } from './middleware/noopen.middleware'; -import { NoSniffMiddleware } from '.'; import { XSSProtectionMiddleware } from './middleware/xssProtection.middleware'; import { CSPMiddleware } from './middleware/csp.middleware'; import { SecurityHelper } from './middleware/helper'; +import { ReferrerPolicyMiddleware } from './middleware/refererPolicy.middleware'; +import { MethodNotAllowedMiddleware } from './middleware/methodNotAllowed.middleware' +import { NoSniffMiddleware } from './middleware/nosniff.middleware'; + @Configuration({ namespace: 'security', importConfigs: [ @@ -34,27 +37,15 @@ export class SecurityConfiguration { .getApplications(['koa', 'faas', 'express', 'egg']) .forEach(app => { app.useMiddleware(SecurityHelper); - if (this.security.csrf?.enable) { - app.useMiddleware(CsrfMiddleware); - } - if (this.security.csp?.enable) { - app.useMiddleware(CSPMiddleware); - } - if (this.security.xframe?.enable) { - app.useMiddleware(XFrameMiddleware); - } - if (this.security.hsts?.enable) { - app.useMiddleware(HSTSMiddleware); - } - if (this.security.noopen?.enable) { - app.useMiddleware(NoOpenMiddleware); - } - if (this.security.nosniff?.enable) { - app.useMiddleware(NoSniffMiddleware); - } - if (this.security.xssProtection?.enable) { - app.useMiddleware(XSSProtectionMiddleware); - } + app.useMiddleware(CsrfMiddleware); + app.useMiddleware(CSPMiddleware); + app.useMiddleware(XFrameMiddleware); + app.useMiddleware(HSTSMiddleware); + app.useMiddleware(NoOpenMiddleware); + app.useMiddleware(NoSniffMiddleware); + app.useMiddleware(XSSProtectionMiddleware); + app.useMiddleware(ReferrerPolicyMiddleware); + app.useMiddleware(MethodNotAllowedMiddleware); }); } } diff --git a/packages/security/src/constants.ts b/packages/security/src/constants.ts new file mode 100644 index 000000000000..78d1a0fbe7b5 --- /dev/null +++ b/packages/security/src/constants.ts @@ -0,0 +1,14 @@ +// https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Referrer-Policy +export const ALLOWED_POLICIES_ENUM = [ + 'no-referrer', + 'no-referrer-when-downgrade', + 'origin', + 'origin-when-cross-origin', + 'same-origin', + 'strict-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url', + '', +] as const; + +export const METHODS_NOT_ALLOWED = ['trace', 'track']; diff --git a/packages/security/src/error.ts b/packages/security/src/error.ts index 9b30d2aa365e..eb63bca10595 100644 --- a/packages/security/src/error.ts +++ b/packages/security/src/error.ts @@ -6,3 +6,9 @@ export class CSRFError extends httpError.ForbiddenError { super(message || 'csrf error'); } } + +export class ReferrerPolicyNotAllowedError extends httpError.InternalServerErrorError { + constructor(policy) { + super(`Current policy ${policy} not allowed`); + } +} diff --git a/packages/security/src/index.ts b/packages/security/src/index.ts index 6a6811d92207..9f131a0a66fc 100644 --- a/packages/security/src/index.ts +++ b/packages/security/src/index.ts @@ -7,4 +7,6 @@ export * from './middleware/noopen.middleware'; export * from './middleware/nosniff.middleware'; export * from './middleware/xssProtection.middleware'; export * from './middleware/csp.middleware'; +export * from './middleware/refererPolicy.middleware'; +export * from './middleware/methodNotAllowed.middleware'; export * from './middleware/helper'; diff --git a/packages/security/src/interface.ts b/packages/security/src/interface.ts index 301ddb481dba..3a5f24ff3f25 100644 --- a/packages/security/src/interface.ts +++ b/packages/security/src/interface.ts @@ -1,4 +1,6 @@ import { IgnoreMatcher } from '@midwayjs/core'; +import { ALLOWED_POLICIES_ENUM } from './constants'; +import type { CookieSetOptions } from '@midwayjs/cookies'; export interface SecurityOptions { /** @@ -32,10 +34,19 @@ export interface SecurityOptions { */ nosniff: Partial; /** - * whether enable IE8 XSS Filter, default is open + * whether enable IE8 XSS Filter * default enable */ xssProtection: Partial; + /** + * whether enable Referrer-Policy + * default not enable and value equals no-referrer-when-downgrade + */ + referrerPolicy: Partial; + /** + * whether enable methodnoallow + */ + methodnoallow: Partial; } export interface SecurityCSRFOptions extends SecurityEnableOptions { @@ -65,7 +76,11 @@ export interface SecurityCSRFOptions extends SecurityEnableOptions { */ queryName: string; refererWhiteList: string[]; + /** + * @deprecated use cookieOptions.domain + */ cookieDomain: (context: any) => string; + cookieOptions: CookieSetOptions; } export interface SecurityXFrameOptions extends SecurityEnableOptions { @@ -88,6 +103,13 @@ export interface SecurityCSPOptions extends SecurityEnableOptions { reportOnly: boolean; supportIE: boolean; } + +export interface SecurityReferrerPolicyOptions extends SecurityEnableOptions { + value: typeof ALLOWED_POLICIES_ENUM[number]; +} + +export interface SecurityMethodNoAllowOptions extends SecurityEnableOptions {} + export interface SecurityEnableOptions { enable: boolean; match?: IgnoreMatcher | IgnoreMatcher []; diff --git a/packages/security/src/middleware/base.ts b/packages/security/src/middleware/base.ts index 97ca5faa8758..5cc9095d651b 100644 --- a/packages/security/src/middleware/base.ts +++ b/packages/security/src/middleware/base.ts @@ -24,17 +24,23 @@ export abstract class BaseMiddleware implements IMiddleware { } resolve(app) { - if (app.getFrameworkType() === MidwayFrameworkType.WEB_EXPRESS) { - return async (req: any, res, next) => { - return this.compatibleMiddleware(req, req, res, next); - }; - } else { - return async (ctx, next) => { - return this.compatibleMiddleware(ctx, ctx.request, ctx, next); - }; + if (this.security?.[this.securityName()]?.enable) { + if (app.getFrameworkType() === MidwayFrameworkType.WEB_EXPRESS) { + return async (req: any, res, next) => { + return this.compatibleMiddleware(req, req, res, next); + }; + } else { + return async (ctx, next) => { + return this.compatibleMiddleware(ctx, ctx.request, ctx, next); + }; + } } } + protected getSecurityPolicyConfig() { + return this.security?.[this.securityName()] || {}; + } + abstract compatibleMiddleware(context, req, res, next); abstract securityName(): string; } diff --git a/packages/security/src/middleware/csrf.middleware.ts b/packages/security/src/middleware/csrf.middleware.ts index 9dea97838f74..fa4fd4587c30 100644 --- a/packages/security/src/middleware/csrf.middleware.ts +++ b/packages/security/src/middleware/csrf.middleware.ts @@ -129,18 +129,30 @@ export class CsrfMiddleware extends BaseMiddleware { } const secret = tokens.secretSync(); context[NEW_CSRF_SECRET] = secret; - const { useSession, sessionName, cookieDomain } = this.security.csrf; - let { cookieName } = this.security.csrf; + let { + // eslint-disable-next-line prefer-const + useSession, + // eslint-disable-next-line prefer-const + sessionName, + // eslint-disable-next-line prefer-const + cookieDomain, + cookieName, + // eslint-disable-next-line prefer-const + cookieOptions = {}, + } = this.security.csrf; if (useSession) { context.session[sessionName] = secret; } else { - const cookieOpts = { - domain: cookieDomain && cookieDomain(request), - signed: false, - httpOnly: false, - overwrite: true, - }; + const cookieOpts = Object.assign( + { + domain: cookieDomain && cookieDomain(request), + signed: false, + httpOnly: false, + overwrite: true, + }, + cookieOptions + ); // cookieName support array. so we can change csrf cookie name smoothly if (!Array.isArray(cookieName)) { cookieName = [cookieName]; diff --git a/packages/security/src/middleware/methodNotAllowed.middleware.ts b/packages/security/src/middleware/methodNotAllowed.middleware.ts new file mode 100644 index 000000000000..bf7c6b3408bd --- /dev/null +++ b/packages/security/src/middleware/methodNotAllowed.middleware.ts @@ -0,0 +1,31 @@ +import { httpError, Middleware } from '@midwayjs/core'; +import { BaseMiddleware } from './base'; +import * as methods from 'methods'; +import { METHODS_NOT_ALLOWED } from '../constants'; + +/** + * https://www.owasp.org/index.php/Cross_Site_Tracing + * http://jsperf.com/find-by-map-with-find-by-array + */ +@Middleware() +export class MethodNotAllowedMiddleware extends BaseMiddleware { + private safeHttpMethodsMap = {}; + constructor() { + super(); + for (const method of methods) { + if (!METHODS_NOT_ALLOWED.includes(method)) { + this.safeHttpMethodsMap[method.toUpperCase()] = true; + } + } + } + async compatibleMiddleware(context, req, res, next) { + // ctx.method is upper case + if (!this.safeHttpMethodsMap[context.method]) { + throw new httpError.MethodNotAllowedError(); + } + return next(); + } + securityName() { + return 'methodnoallow'; + } +} diff --git a/packages/security/src/middleware/refererPolicy.middleware.ts b/packages/security/src/middleware/refererPolicy.middleware.ts new file mode 100644 index 000000000000..f9a3def27128 --- /dev/null +++ b/packages/security/src/middleware/refererPolicy.middleware.ts @@ -0,0 +1,22 @@ +import { Middleware } from '@midwayjs/core'; +import { BaseMiddleware } from './base'; +import { ALLOWED_POLICIES_ENUM } from '../constants'; +import { ReferrerPolicyNotAllowedError } from '../error'; + +@Middleware() +export class ReferrerPolicyMiddleware extends BaseMiddleware { + async compatibleMiddleware(context, req, res, next) { + const result = await next(); + const opts = this.getSecurityPolicyConfig(); + const policy = opts.value; + if (!ALLOWED_POLICIES_ENUM.includes(policy)) { + throw new ReferrerPolicyNotAllowedError(policy); + } + + res.set('referrer-policy', policy); + return result; + } + securityName() { + return 'referrerPolicy'; + } +} diff --git a/packages/security/test/csrf.test.ts b/packages/security/test/csrf.test.ts index 79e973303480..dfbe1990bd62 100644 --- a/packages/security/test/csrf.test.ts +++ b/packages/security/test/csrf.test.ts @@ -451,5 +451,31 @@ describe('test/csrf.test.ts', function () { }); }); + describe('csrf-cookieOptions', function () { + let app; + beforeAll(async () => { + const appDir = join(__dirname, `fixtures/csrf-tmp/csrf-cookieOptions`); + const config = join(appDir, 'src/config/config.default.ts'); + const configuration = join(appDir, 'src/configuration.ts'); + if (existsSync(appDir)) { + await remove(appDir); + } + await copy(csrfBase, appDir); + await remove(join(appDir, 'f.yml')); + await writeFile(configuration, csrfConfigurationCode.replace(/\$\{\s*framework\s*\}/g, `@midwayjs/koa`)); + await writeFile(config, readFileSync(config, 'utf-8') + `\nexport const security = { csrf: {cookieOptions: {httpOnly: true}}};`); + app = await createApp(appDir); + }); + afterAll(async () => { + await close(app); + }); + + it('post with csrf token set to query using session', async () => { + const request = await createHttpRequest(app); + const response = await request.get('/csrf').expect(200).set('Host', 'abc.aaaa.ddd.string.com'); + assert(response.text); + expect(response.headers['set-cookie'][0]).toMatch(/csrfToken=[\w\-]+; path=\/; httponly/); + }); + }); }); diff --git a/packages/security/test/fixtures/csrf-cookieOptions/package.json b/packages/security/test/fixtures/csrf-cookieOptions/package.json new file mode 100644 index 000000000000..621cdc6a4174 --- /dev/null +++ b/packages/security/test/fixtures/csrf-cookieOptions/package.json @@ -0,0 +1,3 @@ +{ + "name": "ali-demo" +} diff --git a/packages/security/test/fixtures/csrf-cookieOptions/src/configuration.ts b/packages/security/test/fixtures/csrf-cookieOptions/src/configuration.ts new file mode 100644 index 000000000000..36efaeacc58e --- /dev/null +++ b/packages/security/test/fixtures/csrf-cookieOptions/src/configuration.ts @@ -0,0 +1,37 @@ +import { Configuration, Controller, Inject, All } from '@midwayjs/core'; +import * as framework from '@midwayjs/koa'; +@Configuration({ + imports: [ + framework, + require('../../../../src') + ], + importConfigs: [ + { + default: { + keys: ['a'], + security: { + csrf: { + enable: true, + cookieOptions: { + httpOnly: true, + }, + }, + } + } + } + ] +}) +export class AutoConfiguration {} + + +@Controller('/') +export class HomeController { + + @Inject() + ctx; + + @All('/ok') + async html() { + return 'ok'; + } +} diff --git a/packages/security/test/fixtures/methodnoallow/package.json b/packages/security/test/fixtures/methodnoallow/package.json new file mode 100644 index 000000000000..621cdc6a4174 --- /dev/null +++ b/packages/security/test/fixtures/methodnoallow/package.json @@ -0,0 +1,3 @@ +{ + "name": "ali-demo" +} diff --git a/packages/security/test/fixtures/methodnoallow/src/configuration.ts b/packages/security/test/fixtures/methodnoallow/src/configuration.ts new file mode 100644 index 000000000000..1ce90a7aae69 --- /dev/null +++ b/packages/security/test/fixtures/methodnoallow/src/configuration.ts @@ -0,0 +1,40 @@ +import { Configuration, Controller, Inject, All } from '@midwayjs/core'; +import * as framework from '@midwayjs/koa'; +@Configuration({ + imports: [ + framework, + require('../../../../src') + ], + importConfigs: [ + { + default: { + keys: ['a'], + security: { + methodnoallow: { + enable: true, + ignore: ['/ok'] + } + } + } + } + ] +}) +export class AutoConfiguration {} + + +@Controller('/') +export class HomeController { + + @Inject() + ctx; + + @All('/ok') + async html() { + return 'ok'; + } + + @All('/notok') + async notok() { + return 'notok'; + } +} diff --git a/packages/security/test/methodnoallow.test.ts b/packages/security/test/methodnoallow.test.ts new file mode 100644 index 000000000000..d9a8de1ad932 --- /dev/null +++ b/packages/security/test/methodnoallow.test.ts @@ -0,0 +1,15 @@ +import { createApp, createHttpRequest } from '@midwayjs/mock'; + +describe('test/methodnoallow.test.ts', () => { + it('should test helper', async () => { + const app = await createApp('methodnoallow'); + let result = await createHttpRequest(app).get('/ok'); + expect(result.status).toEqual(200); + + result = await createHttpRequest(app).trace('/ok'); + expect(result.status).toEqual(200); + + result = await createHttpRequest(app).trace('/notok'); + expect(result.status).toEqual(405); + }); +});