diff --git a/plugins/cors/CHANGELOG.md b/plugins/cors/CHANGELOG.md new file mode 100644 index 0000000000..0fbdeef288 --- /dev/null +++ b/plugins/cors/CHANGELOG.md @@ -0,0 +1,94 @@ +# Changelog + +## [3.0.1](https://github.com/eggjs/egg-cors/compare/v3.0.0...v3.0.1) (2024-04-25) + + +### Bug Fixes + +* let framework known app has custom origin handler or not ([#28](https://github.com/eggjs/egg-cors/issues/28)) ([#29](https://github.com/eggjs/egg-cors/issues/29)) ([b446a98](https://github.com/eggjs/egg-cors/commit/b446a987ee66c38ac07458a5d69992f5a43071a2)) + +## [3.0.0](https://github.com/eggjs/egg-cors/compare/v2.2.3...v3.0.0) (2023-12-11) + + +### ⚠ BREAKING CHANGES + +* drop Node.js < 14 + +### Features + +* use @koa/cors@5.0.0 ([#27](https://github.com/eggjs/egg-cors/issues/27)) ([599e918](https://github.com/eggjs/egg-cors/commit/599e918f18f65c47e41ebf30d659ae439e10207d)) + +2.2.3 / 2019-11-14 +================== + +**fixes** + * [[`7990aa7`](http://github.com/eggjs/egg-cors/commit/7990aa7c92fa2c82bb6e9018b7191852879f1569)] - fix: use new URL instead of url.parse (#22) (Yiyu He <>) + +2.2.2 / 2019-10-25 +================== + +**fixes** + * [[`74e8c26`](http://github.com/eggjs/egg-cors/commit/74e8c264ddbc3aa4b926efa7696a310ef3dabe71)] - fix: should also check origin with port (#21) (Yelmor <>) + +**others** + * [[`53ffc7e`](http://github.com/eggjs/egg-cors/commit/53ffc7ec2cb4d119dc2e6b76bbccb0ed7a3712e9)] - chore: update travis (TZ | 天猪 <>) + +2.2.1 / 2019-10-22 +================== + +**fixes** + * [[`499c2b1`](http://github.com/eggjs/egg-cors/commit/499c2b1cd730b4a9d5d7811946661cc9b475326d)] - fix: second-level domain and port support misjudgement (#20) (Khaidi Chu <>) + +2.2.0 / 2019-03-11 +================== + +**features** + * [[`30a1b8c`](http://github.com/eggjs/egg-cors/commit/30a1b8c8cf58cacd208f86905d334588db523b8e)] - feat: use koa/cors@3, support options.origin to be async function (#19) (Yiyu He <>) + +2.1.2 / 2018-11-30 +================== + + * chore: typings of origin params (#18) + +2.1.1 / 2018-10-01 +================== + + * deps: update (#17) + * chore: add types of cors option. (#15) + * docs: update readme (#16) + +2.1.0 / 2018-07-11 +================== + +**others** + * [[`ff8b7da`](http://github.com/eggjs/egg-cors/commit/ff8b7dab9c9acff5f5319ecca1fe0df5d1ebfaf8)] - chore(typings): add interface Config['cors'] (#12) (waiting <>) + +2.0.0 / 2017-11-23 +================== + +**others** + * [[`120639f`](http://github.com/eggjs/egg-cors/commit/120639fb784ed1ea71eff071124d3f242b52ab72)] - refactor: use async function and support egg@2 (#9) (Yiyu He <>) + +1.2.0 / 2017-07-24 +================== + + * deps: update deps (#8) + * feat: add config for Access-Control-Allow-Origin (#7) + +1.1.0 / 2017-03-24 +================== + + * feat: update to egg@1.0 and docs fix (#6) + * fix: adjust with new test (#4) + +1.0.0 / 2016-11-04 +================== + + * chore: update deps and test on node v7 (#3) + * doc: add configuration information on how to pass in domain whitelist (#2) + * test: add tests (#1) + +0.0.2 / 2016-07-15 +================== + + * init diff --git a/plugins/cors/LICENSE b/plugins/cors/LICENSE new file mode 100644 index 0000000000..8363a1e45b --- /dev/null +++ b/plugins/cors/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Alibaba Group Holding Limited and other contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/cors/README.md b/plugins/cors/README.md new file mode 100644 index 0000000000..bf37a32822 --- /dev/null +++ b/plugins/cors/README.md @@ -0,0 +1,86 @@ +# @eggjs/cors + +[![NPM version][npm-image]][npm-url] +[![Known Vulnerabilities][snyk-image]][snyk-url] +[![npm download][download-image]][download-url] +[![Node.js Version](https://img.shields.io/node/v/@eggjs/cors.svg?style=flat)](https://nodejs.org/en/download/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) + +[npm-image]: https://img.shields.io/npm/v/@eggjs/cors.svg?style=flat-square +[npm-url]: https://npmjs.org/package/@eggjs/cors +[snyk-image]: https://snyk.io/test/npm/@eggjs/cors/badge.svg?style=flat-square +[snyk-url]: https://snyk.io/test/npm/@eggjs/cors +[download-image]: https://img.shields.io/npm/dm/@eggjs/cors.svg?style=flat-square +[download-url]: https://npmjs.org/package/@eggjs/cors + +[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) plugin for egg, based on [@koa/cors](https://github.com/koajs/cors). + +## Install + +```bash +$ npm i @eggjs/cors --save +``` + +## Usage + +```ts +// {app_root}/config/plugin.ts +import corsPlugin from '@eggjs/cors'; + +export default { + ...corsPlugin(), +}; +``` + +`@eggjs/cors` works internally with [@eggjs/security](https://github.com/eggjs/egg/tree/next/plugins/security). By defining the property of `domainWhiteList` on object `security`, you have successfully informed the framework to whitelist the passed domains. + +When you make a request from client side, **egg** should return an `Access-Control-Allow-Origin` response header with the domain that you passed in along with the payload and status code _200_. + +```ts +// {app_root}/config/config.default.ts +export default { + security: { + domainWhiteList: ['http://localhost:4200'], + }, +}; +``` + +## Configuration + +Support all configurations in [@koa/cors](https://github.com/koajs/cors). + +```ts +// {app_root}/config/config.default.ts +export default { + cors: { + // {string|Function} origin: '*', + // {string|Array} allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH' + }, +}; +``` + +If the `origin` is set, the plugin will follow it to set the `Access-Control-Allow-Origin` and ignore the `security.domainWhiteList`. Otherwise, the `security.domainWhiteList` which is default will take effect as described above. + +## Security + +Only in safe domain list support CORS when security plugin enabled. + +## Questions & Suggestions + +Please open an issue [here](https://github.com/eggjs/egg/issues). + +## License + +[MIT](LICENSE) + + + +## Contributors + +| [
atian25](https://github.com/atian25)
| [
dead-horse](https://github.com/dead-horse)
| [
fengmk2](https://github.com/fengmk2)
| [
brickyang](https://github.com/brickyang)
| [
sinchang](https://github.com/sinchang)
| [
XadillaX](https://github.com/XadillaX)
| +| :---------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------: | +| [
mattma](https://github.com/mattma)
| [
SoraYama](https://github.com/SoraYama)
| [
Yelmor](https://github.com/Yelmor)
| [
angela-1](https://github.com/angela-1)
| [
waitingsong](https://github.com/waitingsong)
| + +This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Mon Dec 11 2023 13:25:00 GMT+0800`. + + diff --git a/plugins/cors/package.json b/plugins/cors/package.json new file mode 100644 index 0000000000..237374f52e --- /dev/null +++ b/plugins/cors/package.json @@ -0,0 +1,68 @@ +{ + "name": "@eggjs/cors", + "version": "4.0.2-beta.26", + "description": "cors support for egg", + "keywords": [ + "cors", + "egg", + "egg-plugin", + "eggPlugin" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/plugins/cors", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "license": "MIT", + "author": "dead-horse", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "plugins/cors" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./app": "./src/app.ts", + "./app/middleware/cors": "./src/app/middleware/cors.ts", + "./config/config.default": "./src/config/config.default.ts", + "./types": "./src/types.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./app": "./dist/app.js", + "./app/middleware/cors": "./dist/app/middleware/cors.js", + "./config/config.default": "./dist/config/config.default.js", + "./types": "./dist/types.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@koa/cors": "catalog:" + }, + "devDependencies": { + "@eggjs/mock": "workspace:*", + "@eggjs/security": "workspace:*", + "@types/koa__cors": "catalog:", + "@types/node": "catalog:", + "egg": "workspace:*", + "typescript": "catalog:" + }, + "peerDependencies": { + "egg": "workspace:*" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/plugins/cors/src/app.ts b/plugins/cors/src/app.ts new file mode 100644 index 0000000000..6c4dc73267 --- /dev/null +++ b/plugins/cors/src/app.ts @@ -0,0 +1,45 @@ +import type { Application, Context, ILifecycleBoot } from 'egg'; + +export default class AppBoot implements ILifecycleBoot { + private readonly app; + + constructor(app: Application) { + this.app = app; + } + + configWillLoad(): void { + const app = this.app; + // put before other core middlewares + app.config.coreMiddleware.unshift('cors'); + + const corsConfig = app.config.cors; + if (!corsConfig) return; + + // if the security plugin is enabled and no `origin` config is provided, + // only safe domains are allowed to support CORS. + corsConfig.hasCustomOriginHandler = !!corsConfig.origin; + if (corsConfig.origin) return; + + corsConfig.origin = function corsOrigin(ctx: Context) { + // origin is {protocol}{hostname}{port}... + // `ctx.get()` may return `string[]` for repeated headers + const rawOrigin = ctx.get('origin'); + const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin; + if (!origin) return ''; + + if (typeof ctx.isSafeDomain !== 'function') return origin; + + let parsedUrl: URL; + try { + parsedUrl = new URL(origin); + } catch { + return ''; + } + + if (ctx.isSafeDomain(parsedUrl.hostname) || ctx.isSafeDomain(origin)) { + return origin; + } + return ''; + }; + } +} diff --git a/plugins/cors/src/app/middleware/cors.ts b/plugins/cors/src/app/middleware/cors.ts new file mode 100644 index 0000000000..1490b654e6 --- /dev/null +++ b/plugins/cors/src/app/middleware/cors.ts @@ -0,0 +1,3 @@ +import cors from '@koa/cors'; + +export default cors; diff --git a/plugins/cors/src/config/config.default.ts b/plugins/cors/src/config/config.default.ts new file mode 100644 index 0000000000..eda940bd43 --- /dev/null +++ b/plugins/cors/src/config/config.default.ts @@ -0,0 +1,62 @@ +import type { Context, PartialEggConfig } from 'egg'; + +export interface CorsConfig { + /** + * `Access-Control-Allow-Origin`, default to the request `Origin` header. + * + * When the `security` plugin is enabled and no custom `origin` is provided, + * only safe domains are allowed. + */ + origin?: string | ((ctx: Context) => string | PromiseLike); + /** + * `Access-Control-Allow-Methods` + */ + allowMethods?: string | string[]; + /** + * `Access-Control-Expose-Headers` + */ + exposeHeaders?: string | string[]; + /** + * `Access-Control-Allow-Headers` + */ + allowHeaders?: string | string[]; + /** + * `Access-Control-Max-Age` in seconds + */ + maxAge?: string | number; + /** + * `Access-Control-Allow-Credentials` + */ + credentials?: boolean | ((ctx: Context) => boolean | PromiseLike); + /** + * Add CORS headers to error responses as well + */ + keepHeadersOnError?: boolean; + /** + * `Access-Control-Allow-Private-Network` + */ + privateNetworkAccess?: boolean; + /** + * Set to `true` to add `Cross-Origin-Opener-Policy` and + * `Cross-Origin-Embedder-Policy` headers + */ + secureContext?: boolean; + /** + * Set to `true` to skip `Access-Control-Allow-Origin` when the request has + * no `Origin` header + */ + allowAllOrigins?: boolean; + /** + * Whether the user supplied a custom `origin` handler. + * + * Filled in automatically by the plugin, do not set this manually. + * @private + */ + hasCustomOriginHandler?: boolean; +} + +const config: PartialEggConfig = { + cors: {}, +}; + +export default config; diff --git a/plugins/cors/src/index.ts b/plugins/cors/src/index.ts new file mode 100644 index 0000000000..b5d6abc1f3 --- /dev/null +++ b/plugins/cors/src/index.ts @@ -0,0 +1,22 @@ +import './types.ts'; +import { definePluginFactory, type EggPluginFactory } from 'egg'; + +/** + * CORS plugin + * + * Usage: + * ```ts + * // config/plugin.ts + * import corsPlugin from '@eggjs/cors'; + * + * export default { + * ...corsPlugin(), + * }; + * ``` + */ +export default definePluginFactory({ + name: 'cors', + enable: true, + path: import.meta.dirname, + optionalDependencies: ['security'], +}) as EggPluginFactory; diff --git a/plugins/cors/src/types.ts b/plugins/cors/src/types.ts new file mode 100644 index 0000000000..405d3768a3 --- /dev/null +++ b/plugins/cors/src/types.ts @@ -0,0 +1,13 @@ +import type { CorsConfig } from './config/config.default.ts'; + +declare module 'egg' { + // add EggAppConfig overrides types + interface EggAppConfig { + /** + * cors options + * @member Config#cors + * @see https://github.com/koajs/cors#corsoptions + */ + cors?: CorsConfig; + } +} diff --git a/plugins/cors/test/cors.default-config.test.ts b/plugins/cors/test/cors.default-config.test.ts new file mode 100644 index 0000000000..54c978dfb9 --- /dev/null +++ b/plugins/cors/test/cors.default-config.test.ts @@ -0,0 +1,125 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; + +describe('test/cors.default-config.test.ts', () => { + let app: MockApplication; + beforeAll(() => { + app = mm.app({ + baseDir: path.join(import.meta.dirname, 'fixtures', 'apps', 'cors-default-config'), + }); + return app.ready(); + }); + + afterAll(() => app.close()); + + afterEach(() => mm.restore()); + + it('should not set `Access-Control-Allow-Origin` when request Origin header missing', () => { + return app + .httpRequest() + .get('/') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert.equal(res.headers['access-control-allow-origin'], undefined); + }) + .expect(200); + }); + + it('should hasCustomOriginHandler set to false', () => { + return app.httpRequest().get('/config').expect({ hasCustomOriginHandler: false }).expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` to request origin header', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs.org') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert.equal(res.headers['access-control-allow-origin'], undefined); + assert.equal(res.headers['access-control-allow-credentials'], undefined); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` to request origin header with second-level domain', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'http://test.eggjs.org') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` to when white list domain is empty', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'https://a.com') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` on POST request', () => { + app.mockCsrf(); + return app + .httpRequest() + .post('/') + .set('Origin', 'http://eggjs.org') + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` when origin not in white list', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs-black.org') + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` when origin = http://eggjs.org!.evil.com', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs.org!.evil.com') + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` when origin = /foo', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', '/foo') + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); +}); diff --git a/plugins/cors/test/cors.origin-function.test.ts b/plugins/cors/test/cors.origin-function.test.ts new file mode 100644 index 0000000000..511a4f8dd7 --- /dev/null +++ b/plugins/cors/test/cors.origin-function.test.ts @@ -0,0 +1,67 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; + +describe('test/cors.origin-function.test.ts', () => { + let app: MockApplication; + beforeAll(() => { + app = mm.app({ + baseDir: path.join(import.meta.dirname, 'fixtures', 'apps', 'cors.origin-function'), + }); + return app.ready(); + }); + + afterAll(() => app.close()); + + afterEach(() => mm.restore()); + + it('should not set `Access-Control-Allow-Origin` when request Origin header missing', () => { + return app + .httpRequest() + .get('/') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert.equal(res.headers['access-control-allow-origin'], undefined); + }) + .expect(200); + }); + + it('should hasCustomOriginHandler set to true', () => { + return app.httpRequest().get('/config').expect({ hasCustomOriginHandler: true }).expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to request origin header', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` on POST request', () => { + app.mockCsrf(); + return app + .httpRequest() + .post('/') + .set('Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Credentials', 'true') + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` equal to the config not the white list', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs-white.org') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org') + .expect('access-control-allow-credentials', 'true') + .expect(200); + }); +}); diff --git a/plugins/cors/test/cors.origin.test.ts b/plugins/cors/test/cors.origin.test.ts new file mode 100644 index 0000000000..fc66dfc13b --- /dev/null +++ b/plugins/cors/test/cors.origin.test.ts @@ -0,0 +1,63 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; + +describe('test/cors.origin.test.ts', () => { + let app: MockApplication; + beforeAll(() => { + app = mm.app({ + baseDir: path.join(import.meta.dirname, 'fixtures', 'apps', 'cors.origin'), + }); + return app.ready(); + }); + + afterAll(() => app.close()); + + afterEach(() => mm.restore()); + + it('should alway set `Access-Control-Allow-Origin` to config.origin=string when request Origin header missing', () => { + return app + .httpRequest() + .get('/') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert.equal(res.headers['access-control-allow-origin'], 'http://eggjs.org'); + }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to request origin header', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` on POST request', () => { + app.mockCsrf(); + return app + .httpRequest() + .post('/') + .set('Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Credentials', 'true') + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` equal to the config not the white list', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs-white.org') + .expect('Access-Control-Allow-Origin', app.config.cors?.origin as string) + .expect('access-control-allow-credentials', 'true') + .expect(200); + }); +}); diff --git a/plugins/cors/test/cors.private-network.test.ts b/plugins/cors/test/cors.private-network.test.ts new file mode 100644 index 0000000000..d4c5ddb46a --- /dev/null +++ b/plugins/cors/test/cors.private-network.test.ts @@ -0,0 +1,60 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; + +describe('test/cors.private-network.test.ts', () => { + let app: MockApplication; + beforeAll(() => { + app = mm.app({ + baseDir: path.join(import.meta.dirname, 'fixtures', 'apps', 'cors.private-network'), + }); + return app.ready(); + }); + + afterAll(() => app.close()); + + afterEach(() => mm.restore()); + + it('should not set `Access-Control-Allow-Private-Network` when request Access-Control-Request-Private-Network header missing', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'https://eggjs.org') + .set('Access-Control-Request-Method', 'GET') + .expect('Access-Control-Allow-Origin', 'https://eggjs.org') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert(!res.headers['access-control-allow-private-network']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Private-Network` to non-OPTIONS request', () => { + return app + .httpRequest() + .post('/') + .set('Origin', 'https://eggjs.org') + .set('Access-Control-Request-Private-Network', 'true') + .expect('Access-Control-Allow-Origin', 'https://eggjs.org') + .expect((res: any) => { + assert(!res.headers['access-control-allow-private-network']); + }) + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Private-Network` on OPTIONS request', () => { + app.mockCsrf(); + return app + .httpRequest() + .options('/') + .set('Origin', 'https://eggjs.org') + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Private-Network', 'true') + .expect('Access-Control-Allow-Origin', 'https://eggjs.org') + .expect('Access-Control-Allow-Private-Network', 'true') + .expect(204); + }); +}); diff --git a/plugins/cors/test/cors.test.ts b/plugins/cors/test/cors.test.ts new file mode 100644 index 0000000000..8f967b5e49 --- /dev/null +++ b/plugins/cors/test/cors.test.ts @@ -0,0 +1,146 @@ +import { strict as assert } from 'node:assert'; +import path from 'node:path'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; + +describe('test/cors.test.ts', () => { + let app: MockApplication; + beforeAll(() => { + app = mm.app({ + baseDir: path.join(import.meta.dirname, 'fixtures', 'apps', 'cors'), + }); + return app.ready(); + }); + + afterAll(() => app.close()); + + afterEach(() => mm.restore()); + + it('should not set `Access-Control-Allow-Origin` when request Origin header missing', () => { + return app + .httpRequest() + .get('/') + .expect({ foo: 'bar' }) + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to request origin header', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to request origin header with second-level domain', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'http://test.eggjs.org') + .expect('Access-Control-Allow-Origin', 'http://test.eggjs.org') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to white list domain with protocol', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'https://a.com') + .expect('Access-Control-Allow-Origin', 'https://a.com') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to white list domain with protocol and port', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'https://b.com:1234') + .expect('Access-Control-Allow-Origin', 'https://b.com:1234') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to white list domain with protocol, wildcard and port', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'https://x.c.com') + .expect('Access-Control-Allow-Origin', 'https://x.c.com') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` to request origin header with port', () => { + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs.org:3721') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org:3721') + .expect('Access-Control-Allow-Credentials', 'true') + .expect({ foo: 'bar' }) + .expect(200); + }); + + it('should set `Access-Control-Allow-Origin` on POST request', () => { + app.mockCsrf(); + return app + .httpRequest() + .post('/') + .set('Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Origin', 'http://eggjs.org') + .expect('Access-Control-Allow-Credentials', 'true') + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` when origin not in white list', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs-black.org') + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` when origin = http://eggjs.org!.evil.com', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', 'http://eggjs.org!.evil.com') + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); + + it('should not set `Access-Control-Allow-Origin` when origin = /foo', () => { + app.mockCsrf(); + return app + .httpRequest() + .get('/') + .set('Origin', '/foo') + .expect((res: any) => { + assert(!res.headers['access-control-allow-origin']); + assert(!res.headers['access-control-allow-credentials']); + }) + .expect(200); + }); +}); diff --git a/plugins/cors/test/fixtures/apps/cors-default-config/app/router.js b/plugins/cors/test/fixtures/apps/cors-default-config/app/router.js new file mode 100644 index 0000000000..d6dba33581 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors-default-config/app/router.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = (app) => { + app.get('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); + + app.get('/config', async (ctx) => { + ctx.body = { + hasCustomOriginHandler: ctx.app.config.cors.hasCustomOriginHandler, + }; + }); + + app.post('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); +}; diff --git a/plugins/cors/test/fixtures/apps/cors-default-config/config/config.default.js b/plugins/cors/test/fixtures/apps/cors-default-config/config/config.default.js new file mode 100644 index 0000000000..5f2fb6f5da --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors-default-config/config/config.default.js @@ -0,0 +1,13 @@ +exports.keys = 'foo'; + +exports.cors = {}; + +exports.logger = { + consoleLevel: 'NONE', + level: 'NONE', + coreLogger: { + consoleLevel: 'NONE', + level: 'NONE', + }, + disableConsoleAfterReady: true, +}; diff --git a/plugins/cors/test/fixtures/apps/cors-default-config/config/plugin.js b/plugins/cors/test/fixtures/apps/cors-default-config/config/plugin.js new file mode 100644 index 0000000000..2742bd3f01 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors-default-config/config/plugin.js @@ -0,0 +1,4 @@ +exports.cors = { + enable: true, + package: '@eggjs/cors', +}; diff --git a/plugins/cors/test/fixtures/apps/cors-default-config/package.json b/plugins/cors/test/fixtures/apps/cors-default-config/package.json new file mode 100644 index 0000000000..68ec2ce6b6 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors-default-config/package.json @@ -0,0 +1,3 @@ +{ + "name": "cors-default-config" +} diff --git a/plugins/cors/test/fixtures/apps/cors.origin-function/app/router.js b/plugins/cors/test/fixtures/apps/cors.origin-function/app/router.js new file mode 100644 index 0000000000..d6dba33581 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin-function/app/router.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = (app) => { + app.get('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); + + app.get('/config', async (ctx) => { + ctx.body = { + hasCustomOriginHandler: ctx.app.config.cors.hasCustomOriginHandler, + }; + }); + + app.post('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); +}; diff --git a/plugins/cors/test/fixtures/apps/cors.origin-function/config/config.default.js b/plugins/cors/test/fixtures/apps/cors.origin-function/config/config.default.js new file mode 100644 index 0000000000..b689a7ea65 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin-function/config/config.default.js @@ -0,0 +1,23 @@ +exports.keys = 'foo'; + +exports.cors = { + async origin(ctx) { + if (!ctx.get('origin')) return ''; + return 'http://eggjs.org'; + }, + credentials: true, +}; + +exports.security = { + domainWhiteList: ['eggjs-white.org'], +}; + +exports.logger = { + consoleLevel: 'NONE', + level: 'NONE', + coreLogger: { + consoleLevel: 'NONE', + level: 'NONE', + }, + disableConsoleAfterReady: true, +}; diff --git a/plugins/cors/test/fixtures/apps/cors.origin-function/config/plugin.js b/plugins/cors/test/fixtures/apps/cors.origin-function/config/plugin.js new file mode 100644 index 0000000000..2742bd3f01 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin-function/config/plugin.js @@ -0,0 +1,4 @@ +exports.cors = { + enable: true, + package: '@eggjs/cors', +}; diff --git a/plugins/cors/test/fixtures/apps/cors.origin-function/package.json b/plugins/cors/test/fixtures/apps/cors.origin-function/package.json new file mode 100644 index 0000000000..b5adde49ce --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin-function/package.json @@ -0,0 +1,3 @@ +{ + "name": "cors.origin-function" +} diff --git a/plugins/cors/test/fixtures/apps/cors.origin/app/router.js b/plugins/cors/test/fixtures/apps/cors.origin/app/router.js new file mode 100644 index 0000000000..d0f86e2b93 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin/app/router.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = (app) => { + app.get('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); + + app.post('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); +}; diff --git a/plugins/cors/test/fixtures/apps/cors.origin/config/config.default.js b/plugins/cors/test/fixtures/apps/cors.origin/config/config.default.js new file mode 100644 index 0000000000..614c33d75a --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin/config/config.default.js @@ -0,0 +1,20 @@ +exports.keys = 'foo'; + +exports.cors = { + origin: 'http://eggjs.org', + credentials: true, +}; + +exports.security = { + domainWhiteList: ['eggjs-white.org'], +}; + +exports.logger = { + consoleLevel: 'NONE', + level: 'NONE', + coreLogger: { + consoleLevel: 'NONE', + level: 'NONE', + }, + disableConsoleAfterReady: true, +}; diff --git a/plugins/cors/test/fixtures/apps/cors.origin/config/plugin.js b/plugins/cors/test/fixtures/apps/cors.origin/config/plugin.js new file mode 100644 index 0000000000..2742bd3f01 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin/config/plugin.js @@ -0,0 +1,4 @@ +exports.cors = { + enable: true, + package: '@eggjs/cors', +}; diff --git a/plugins/cors/test/fixtures/apps/cors.origin/package.json b/plugins/cors/test/fixtures/apps/cors.origin/package.json new file mode 100644 index 0000000000..7ed34ec51a --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.origin/package.json @@ -0,0 +1,3 @@ +{ + "name": "cors.origin" +} diff --git a/plugins/cors/test/fixtures/apps/cors.private-network/app/router.js b/plugins/cors/test/fixtures/apps/cors.private-network/app/router.js new file mode 100644 index 0000000000..d0f86e2b93 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.private-network/app/router.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = (app) => { + app.get('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); + + app.post('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); +}; diff --git a/plugins/cors/test/fixtures/apps/cors.private-network/config/config.default.js b/plugins/cors/test/fixtures/apps/cors.private-network/config/config.default.js new file mode 100644 index 0000000000..114c95fe4d --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.private-network/config/config.default.js @@ -0,0 +1,22 @@ +'use strict'; + +exports.keys = 'foo'; + +exports.cors = { + privateNetworkAccess: true, +}; + +exports.security = { + csrf: false, + domainWhiteList: ['.eggjs.org'], +}; + +exports.logger = { + consoleLevel: 'NONE', + level: 'NONE', + coreLogger: { + consoleLevel: 'NONE', + level: 'NONE', + }, + disableConsoleAfterReady: true, +}; diff --git a/plugins/cors/test/fixtures/apps/cors.private-network/config/plugin.js b/plugins/cors/test/fixtures/apps/cors.private-network/config/plugin.js new file mode 100644 index 0000000000..2742bd3f01 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.private-network/config/plugin.js @@ -0,0 +1,4 @@ +exports.cors = { + enable: true, + package: '@eggjs/cors', +}; diff --git a/plugins/cors/test/fixtures/apps/cors.private-network/package.json b/plugins/cors/test/fixtures/apps/cors.private-network/package.json new file mode 100644 index 0000000000..d5a2d1a7ec --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors.private-network/package.json @@ -0,0 +1,3 @@ +{ + "name": "cors.private-network" +} diff --git a/plugins/cors/test/fixtures/apps/cors/app/router.js b/plugins/cors/test/fixtures/apps/cors/app/router.js new file mode 100644 index 0000000000..d0f86e2b93 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors/app/router.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = (app) => { + app.get('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); + + app.post('/', async (ctx) => { + ctx.body = { + foo: 'bar', + }; + }); +}; diff --git a/plugins/cors/test/fixtures/apps/cors/config/config.default.js b/plugins/cors/test/fixtures/apps/cors/config/config.default.js new file mode 100644 index 0000000000..570c5dd28a --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors/config/config.default.js @@ -0,0 +1,25 @@ +exports.keys = 'foo'; + +exports.cors = { + credentials: true, +}; + +exports.security = { + domainWhiteList: [ + '.eggjs.org', + 'https://a.com', + 'https://b.com:1234', + // 'https://*.c.com', + '.c.com', + ], +}; + +exports.logger = { + consoleLevel: 'NONE', + level: 'NONE', + coreLogger: { + consoleLevel: 'NONE', + level: 'NONE', + }, + disableConsoleAfterReady: true, +}; diff --git a/plugins/cors/test/fixtures/apps/cors/config/plugin.js b/plugins/cors/test/fixtures/apps/cors/config/plugin.js new file mode 100644 index 0000000000..2742bd3f01 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors/config/plugin.js @@ -0,0 +1,4 @@ +exports.cors = { + enable: true, + package: '@eggjs/cors', +}; diff --git a/plugins/cors/test/fixtures/apps/cors/package.json b/plugins/cors/test/fixtures/apps/cors/package.json new file mode 100644 index 0000000000..be8a359c74 --- /dev/null +++ b/plugins/cors/test/fixtures/apps/cors/package.json @@ -0,0 +1,3 @@ +{ + "name": "cors" +} diff --git a/plugins/cors/tsconfig.json b/plugins/cors/tsconfig.json new file mode 100644 index 0000000000..4082f16a5d --- /dev/null +++ b/plugins/cors/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8029106a4b..9ab87be449 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,6 +17,7 @@ catalog: '@eggjs/redis': ^3.0.0 '@eggjs/scripts': ^4.0.0 '@fengmk2/ps-tree': ^2.0.1 + '@koa/cors': ^5.0.0 '@oclif/core': ^4.2.0 '@oxc-node/core': ^0.1.0 '@swc-node/register': ^1.11.1 @@ -41,6 +42,7 @@ catalog: '@types/ini': ^4.1.1 '@types/js-beautify': ^1.14.3 '@types/js-yaml': ^4.0.9 + '@types/koa__cors': ^5.0.1 '@types/koa-bodyparser': ^4.3.12 '@types/koa-compose': ^3.2.8 '@types/koa-range': ^0.3.5