diff --git a/.autod.conf.js b/.autod.conf.js deleted file mode 100644 index 5c2c05d..0000000 --- a/.autod.conf.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -module.exports = { - write: true, - prefix: '^', - test: [ - 'test', - 'benchmark', - ], - devdep: [ - 'egg-ci', - 'egg-bin', - 'autod', - 'eslint', - 'eslint-config-egg', - 'supertest', - 'webstorm-disable-index', - ], - exclude: [ - './test/fixtures', - './docs', - './coverage', - ], - registry: 'https://r.cnpmjs.org', -}; diff --git a/.eslintrc b/.eslintrc index c799fe5..9bcdb46 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,6 @@ { - "extends": "eslint-config-egg" + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 48f9944..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,24 +0,0 @@ - - -##### Checklist - - -- [ ] `npm test` passes -- [ ] tests and/or benchmarks are included -- [ ] documentation is changed or added -- [ ] commit message follows commit guidelines - -##### Affected core subsystem(s) - - - -##### Description of change - diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..fd73aac --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,16 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + Job: + name: Node.js + uses: node-modules/github-actions/.github/workflows/node-test.yml@master + with: + version: '18.19.0, 20, 22' + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml new file mode 100644 index 0000000..bac3fac --- /dev/null +++ b/.github/workflows/pkg.pr.new.yml @@ -0,0 +1,23 @@ +name: Publish Any Commit +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run prepublishOnly --if-present + + - run: npx pkg-pr-new publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a2bf04a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,13 @@ +name: Release + +on: + push: + branches: [ master ] + +jobs: + release: + name: Node.js + uses: eggjs/github-actions/.github/workflows/node-release.yml@master + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} diff --git a/.gitignore b/.gitignore index 1a5bf14..c010914 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ logs/ npm-debug.log node_modules/ coverage/ -.idea/ -run/ +test/fixtures/**/run .DS_Store -*.swp - +.tshy* +.eslintcache +dist +package-lock.json +.package-lock.json diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 320fe63..0000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -sudo: false -language: node_js -node_js: - - '8' - - '10' -before_install: - - npm i npminstall -g -install: - - npminstall -script: - - npm run ci -after_script: - - npminstall codecov && codecov diff --git a/History.md b/CHANGELOG.md similarity index 100% rename from History.md rename to CHANGELOG.md diff --git a/README.md b/README.md index 8d2aaa8..4a3625a 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,63 @@ -# egg-jsonp +# @eggjs/jsonp [![NPM version][npm-image]][npm-url] -[![build status][travis-image]][travis-url] +[![Node.js CI](https://github.com/eggjs/jsonp/actions/workflows/nodejs.yml/badge.svg)](https://github.com/eggjs/jsonp/actions/workflows/nodejs.yml) [![Test coverage][codecov-image]][codecov-url] -[![David deps][david-image]][david-url] [![Known Vulnerabilities][snyk-image]][snyk-url] [![npm download][download-image]][download-url] - -[npm-image]: https://img.shields.io/npm/v/egg-jsonp.svg?style=flat-square -[npm-url]: https://npmjs.org/package/egg-jsonp -[travis-image]: https://img.shields.io/travis/eggjs/egg-jsonp.svg?style=flat-square -[travis-url]: https://travis-ci.org/eggjs/egg-jsonp -[codecov-image]: https://img.shields.io/codecov/c/github/eggjs/egg-jsonp.svg?style=flat-square -[codecov-url]: https://codecov.io/github/eggjs/egg-jsonp?branch=master -[david-image]: https://img.shields.io/david/eggjs/egg-jsonp.svg?style=flat-square -[david-url]: https://david-dm.org/eggjs/egg-jsonp -[snyk-image]: https://snyk.io/test/npm/egg-jsonp/badge.svg?style=flat-square -[snyk-url]: https://snyk.io/test/npm/egg-jsonp -[download-image]: https://img.shields.io/npm/dm/egg-jsonp.svg?style=flat-square -[download-url]: https://npmjs.org/package/egg-jsonp +[![Node.js Version](https://img.shields.io/node/v/@eggjs/jsonp.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/jsonp.svg?style=flat-square +[npm-url]: https://npmjs.org/package/@eggjs/jsonp +[codecov-image]: https://img.shields.io/codecov/c/github/eggjs/jsonp.svg?style=flat-square +[codecov-url]: https://codecov.io/github/eggjs/jsonp?branch=master +[snyk-image]: https://snyk.io/test/npm/@eggjs/jsonp/badge.svg?style=flat-square +[snyk-url]: https://snyk.io/test/npm/@eggjs/jsonp +[download-image]: https://img.shields.io/npm/dm/@eggjs/jsonp.svg?style=flat-square +[download-url]: https://npmjs.org/package/@eggjs/jsonp An egg plugin for jsonp support. +## Requirements + +- egg >= 4.x + ## Install ```bash -$ npm i egg-jsonp --save +npm i @eggjs/jsonp ``` ## Usage -```js -// {app_root}/config/plugin.js -exports.jsonp = { - enable: true, - package: 'egg-jsonp', +```ts +// {app_root}/config/plugin.ts + +export default { + jsonp: { + enable: true, + package: '@eggjs/jsonp', + }, }; ``` ## Configuration -* {String|Array} callback - jsonp callback method key, default to `[ '_callback', 'callback' ]` -* {Number} limit - callback method name's max length, default to `50` -* {Boolean} csrf - enable csrf check or not. default to false -* {String|RegExp|Array} whiteList - referrer white list +- {String|Array} callback - jsonp callback method key, default to `[ '_callback', 'callback' ]` +- {Number} limit - callback method name's max length, default to `50` +- {Boolean} csrf - enable csrf check or not. default to false +- {String|RegExp|Array} whiteList - referrer white list if whiteList's type is `RegExp`, referrer must match `whiteList`, pay attention to the first `^` and last `/`. -```js -exports.jsonp = { - whiteList: /^https?:\/\/test.com\//, -} +```ts +export default { + jsonp: { + whiteList: /^https?:\/\/test.com\//, + }, +}; + // matchs referrer: // https://test.com/hello // http://test.com/ @@ -58,10 +65,13 @@ exports.jsonp = { if whiteList's type is `String` and starts with `.`: -```js -exports.jsonp = { - whiteList: '.test.com', +```ts +export default { + jsonp: { + whiteList: '.test.com', + }, }; + // matchs domain test.com: // https://test.com/hello // http://test.com/ @@ -73,10 +83,13 @@ exports.jsonp = { if whiteList's type is `String` and not starts with `.`: -```js -exports.jsonp = { - whiteList: 'sub.test.com', +```ts +export default { + jsonp: { + whiteList: 'sub.test.com', + }, }; + // only matchs domain sub.test.com: // https://sub.test.com/hello // http://sub.test.com/ @@ -84,29 +97,32 @@ exports.jsonp = { whiteList also can be an array: -```js -exports.jsonp = { - whiteList: [ '.foo.com', '.bar.com' ], +```ts +export default { + jsonp: { + whiteList: [ '.foo.com', '.bar.com' ], + }, }; ``` -see [config/config.default.js](https://github.com/eggjs/egg-jsonp/blob/master/config/config.default.js) for more detail. +see [config/config.default.ts](https://github.com/eggjs/jsonp/blob/master/src/config/config.default.ts) for more detail. ## API -* ctx.acceptJSONP - detect if response should be jsonp, readonly +- ctx.acceptJSONP - detect if response should be jsonp, readonly ## Example -In `app/router.js` +In `app/router.ts` -```js +```ts // Create once and use in any router you want to support jsonp. const jsonp = app.jsonp(); + app.get('/default', jsonp, 'jsonp.index'); app.get('/another', jsonp, 'jsonp.another'); -// Customize by create another jsonp middleware with specific sonfigurations. +// Customize by create another jsonp middleware with specific configurations. app.get('/customize', app.jsonp({ callback: 'fn' }), 'jsonp.customize'); ``` @@ -116,5 +132,10 @@ Please open an issue [here](https://github.com/eggjs/egg/issues). ## License -[MIT](https://github.com/eggjs/egg-jsonp/blob/master/LICENSE) +[MIT](LICENSE) + +## Contributors + +[![Contributors](https://contrib.rocks/image?repo=eggjs/jsonp)](https://github.com/eggjs/jsonp/graphs/contributors) +Made with [contributors-img](https://contrib.rocks). diff --git a/app/extend/application.js b/app/extend/application.js deleted file mode 100644 index e702515..0000000 --- a/app/extend/application.js +++ /dev/null @@ -1,113 +0,0 @@ -'use strict'; - -const is = require('is-type-of'); -const url = require('url'); -const { JSONP_CONFIG } = require('../../lib/private_key'); - -module.exports = { - /** - * return a middleware to enable jsonp response. - * will do some security check inside. - * @param {Object} options jsonp options. can override `config.jsonp`. - * @return {Function} jsonp middleware - * @public - */ - jsonp(options) { - const defaultOptions = this.config.jsonp; - options = Object.assign({}, defaultOptions, options); - if (!Array.isArray(options.callback)) options.callback = [ options.callback ]; - - const csrfEnable = this.plugins.security && this.plugins.security.enable // security enable - && this.config.security.csrf && this.config.security.csrf.enable !== false // csrf enable - && options.csrf; // jsonp csrf enabled - - const validateReferrer = options.whiteList && createValidateReferer(options.whiteList); - - if (!csrfEnable && !validateReferrer) { - this.coreLogger.warn('[egg-jsonp] SECURITY WARNING!! csrf check and referrer check are both closed!'); - } - /** - * jsonp request security check, pass if - * - * 1. hit referrer white list - * 2. or pass csrf check - * 3. both check are disabled - * - * @param {Context} ctx request context - */ - function securityAssert(ctx) { - // all disabled. don't need check - if (!csrfEnable && !validateReferrer) return; - - // pass referrer check - const referrer = ctx.get('referrer'); - if (validateReferrer && validateReferrer(referrer)) return; - if (csrfEnable && validateCsrf(ctx)) return; - - const err = new Error('jsonp request security validate failed'); - err.referrer = referrer; - err.status = 403; - throw err; - } - - return async function jsonp(ctx, next) { - const jsonpFunction = getJsonpFunction(ctx.query, options.callback); - - ctx[JSONP_CONFIG] = { - jsonpFunction, - options, - }; - - // before handle request, must do some security checks - securityAssert(ctx); - - await next(); - - // generate jsonp body - ctx.createJsonpBody(ctx.body); - }; - }, -}; - -function createValidateReferer(whiteList) { - if (!Array.isArray(whiteList)) whiteList = [ whiteList ]; - - return function(referrer) { - let parsed = null; - for (const item of whiteList) { - if (is.regExp(item) && item.test(referrer)) { - // regexp(/^https?:\/\/github.com\//): test the referrer with item - return true; - } - - parsed = parsed || url.parse(referrer); - const hostname = parsed.hostname || ''; - - if (item[0] === '.' && - (hostname.endsWith(item) || hostname === item.slice(1))) { - // string start with `.`(.github.com): referrer's hostname must ends with item - return true; - } else if (hostname === item) { - // string not start with `.`(github.com): referrer's hostname must strict equal to item - return true; - } - } - - return false; - }; -} - -function validateCsrf(ctx) { - try { - ctx.assertCsrf(); - return true; - } catch (_) { - return false; - } -} - -function getJsonpFunction(query, callbacks) { - for (const callback of callbacks) { - if (query[callback]) return query[callback]; - } -} diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index d0aa47e..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,15 +0,0 @@ -environment: - matrix: - - nodejs_version: '8' - - nodejs_version: '9' - -install: - - ps: Install-Product node $env:nodejs_version - - npm i npminstall && node_modules\.bin\npminstall - -test_script: - - node --version - - npm --version - - npm run test - -build: off diff --git a/config/config.default.js b/config/config.default.js deleted file mode 100644 index 0217351..0000000 --- a/config/config.default.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -/** - * jsonp options - * @member Config#jsonp - * @property {String} callback - jsonp callback method key, default to `['_callback', 'callback' ]` - * @property {Number} limit - callback method name's max length, default to `50` - * @property {Boolean} csrf - enable csrf check or not. default to false - * @property {String|RegExp|Array} whiteList - referrer white list - */ -exports.jsonp = { - limit: 50, - callback: [ '_callback', 'callback' ], - csrf: false, - whiteList: undefined, -}; diff --git a/lib/private_key.js b/lib/private_key.js deleted file mode 100644 index 9580f00..0000000 --- a/lib/private_key.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; - -exports.JSONP_CONFIG = Symbol('jsonp#config'); diff --git a/package.json b/package.json index acd777e..9537b88 100644 --- a/package.json +++ b/package.json @@ -1,71 +1,91 @@ { - "name": "egg-jsonp", + "name": "@eggjs/jsonp", "version": "2.0.0", "description": "jsonp support for egg", "eggPlugin": { "name": "jsonp", "optionalDependencies": [ "security" - ] + ], + "exports": { + "import": "./dist/esm", + "require": "./dist/commonjs", + "typescript": "./src" + } }, - "files": [ - "app", - "lib", - "config" - ], "keywords": [ "egg", "egg-plugin", "jsonp", "security" ], - "dependencies": { - "is-type-of": "^1.2.0", - "jsonp-body": "^1.0.0" - }, - "devDependencies": { - "autod": "^2.10.1", - "egg": "next", - "egg-bin": "^4.3.5", - "egg-ci": "^1.8.0", - "egg-mock": "^3.13.1", - "eslint": "^4.10.0", - "eslint-config-egg": "^5.1.1", - "supertest": "^3.0.0", - "webstorm-disable-index": "^1.2.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "scripts": { - "test": "npm run lint -- --fix && npm run test-local", - "test-local": "egg-bin test", - "cov": "egg-bin cov", - "lint": "eslint .", - "ci": "npm run lint && npm run cov && egg-bin pkgfiles --check", - "autod": "autod" - }, - "ci": { - "version": "8, 9" - }, "repository": { "type": "git", - "url": "git+https://github.com/eggjs/egg-jsonp.git" + "url": "git+https://github.com/eggjs/jsonp.git" }, "bugs": { "url": "https://github.com/eggjs/egg/issues" }, - "homepage": "https://github.com/eggjs/egg-jsonp#readme", + "homepage": "https://github.com/eggjs/jsonp#readme", "author": "dead-horse", "license": "MIT", - "boilerplate": { - "name": "egg-boilerplate-plugin", - "version": "1.7.0", - "description": "boilerplate for egg plugin", - "repository": { - "type": "git", - "url": "git@github.com:eggjs/egg-boilerplate-plugin.git" + "engines": { + "node": ">= 18.19.0" + }, + "dependencies": { + "@eggjs/core": "^6.2.13", + "jsonp-body": "^2.0.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.1", + "@eggjs/bin": "7", + "@eggjs/mock": "6", + "@eggjs/tsconfig": "1", + "@types/mocha": "10", + "@types/node": "22", + "egg": "4", + "eslint": "8", + "eslint-config-egg": "14", + "rimraf": "6", + "tshy": "3", + "tshy-after": "1", + "typescript": "5" + }, + "scripts": { + "lint": "eslint --cache src test --ext .ts", + "pretest": "npm run clean && npm run lint -- --fix", + "test": "egg-bin test", + "preci": "npm run clean && npm run lint", + "ci": "egg-bin cov", + "postci": "npm run prepublishOnly && npm run clean", + "clean": "rimraf dist", + "prepublishOnly": "tshy && tshy-after && attw --pack" + }, + "type": "module", + "tshy": { + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } }, - "homepage": "https://github.com/eggjs/egg-boilerplate-plugin" - } + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "types": "./dist/commonjs/index.d.ts", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js" } diff --git a/src/app/extend/application.ts b/src/app/extend/application.ts new file mode 100644 index 0000000..8f989e5 --- /dev/null +++ b/src/app/extend/application.ts @@ -0,0 +1,131 @@ +import { debuglog } from 'node:util'; +import { parse as urlParse, type UrlWithStringQuery } from 'node:url'; +import type { ParsedUrlQuery } from 'node:querystring'; +import { EggCore, type MiddlewareFunc } from '@eggjs/core'; +import { JSONP_CONFIG } from '../../lib/private_key.js'; +import { JSONPConfig } from '../../types.js'; +import { JSONPForbiddenReferrerError } from '../../error/JSONPForbiddenReferrerError.js'; +import JSONPContext from './context.js'; + +const debug = debuglog('@egg/jsonp/app/extend/application'); + +export default class JSONPApplication extends EggCore { + /** + * return a middleware to enable jsonp response. + * will do some security check inside. + * @public + */ + jsonp(initOptions: Partial = {}): MiddlewareFunc { + const options = { + ...this.config.jsonp, + ...initOptions, + } as JSONPConfig & { callback: string[] }; + if (!Array.isArray(options.callback)) { + options.callback = [ options.callback ]; + } + + const csrfEnable = this.plugins.security && this.plugins.security.enable // security enable + && this.config.security.csrf && this.config.security.csrf.enable !== false // csrf enable + && options.csrf; // jsonp csrf enabled + + const validateReferrer = options.whiteList && createValidateReferer(options.whiteList); + + if (!csrfEnable && !validateReferrer) { + this.coreLogger.warn('[@eggjs/jsonp] SECURITY WARNING!! csrf check and referrer check are both closed!'); + } + /** + * jsonp request security check, pass if + * + * 1. hit referrer white list + * 2. or pass csrf check + * 3. both check are disabled + * + * @param {Context} ctx request context + */ + function securityAssert(ctx: JSONPContext) { + // all disabled. don't need check + if (!csrfEnable && !validateReferrer) return; + + // pass referrer check + const referrer = ctx.get('referrer'); + if (validateReferrer && validateReferrer(referrer)) return; + if (csrfEnable && validateCsrf(ctx)) return; + + throw new JSONPForbiddenReferrerError( + 'jsonp request security validate failed', + referrer, + 403); + } + + return async function jsonp(ctx: JSONPContext, next) { + const jsonpFunction = getJsonpFunction(ctx.query, options.callback); + + ctx[JSONP_CONFIG] = { + jsonpFunction, + options, + }; + + // before handle request, must do some security checks + securityAssert(ctx); + + await next(); + + // generate jsonp body + ctx.createJsonpBody(ctx.body); + }; + } +} + +function createValidateReferer(whiteList: Required['whiteList']) { + if (!Array.isArray(whiteList)) { + whiteList = [ whiteList ]; + } + + return (referrer: string) => { + let parsed: UrlWithStringQuery | undefined; + for (const rule of whiteList) { + if (rule instanceof RegExp) { + if (rule.test(referrer)) { + // regexp(/^https?:\/\/github.com\//): test the referrer with rule + return true; + } + continue; + } + + parsed = parsed ?? urlParse(referrer); + const hostname = parsed.hostname || ''; + + // check if referrer's hostname match the string rule + if (rule[0] === '.' && + (hostname.endsWith(rule) || hostname === rule.slice(1))) { + // string start with `.`(.github.com): referrer's hostname must ends with rule + return true; + } else if (hostname === rule) { + // string not start with `.`(github.com): referrer's hostname must strict equal to rule + return true; + } + } + + // no rule matched + return false; + }; +} + +function validateCsrf(ctx: any) { + try { + // TODO(fengmk2): remove this when @eggjs/security support ctx.assertCsrf type define + ctx.assertCsrf(); + return true; + } catch (err) { + debug('validate csrf failed: %s', err); + return false; + } +} + +function getJsonpFunction(query: ParsedUrlQuery, callbacks: string[]) { + for (const callback of callbacks) { + if (query[callback]) { + return query[callback] as string; + } + } +} diff --git a/app/extend/context.js b/src/app/extend/context.ts similarity index 52% rename from app/extend/context.js rename to src/app/extend/context.ts index 87458ac..b1dedea 100644 --- a/app/extend/context.js +++ b/src/app/extend/context.ts @@ -1,26 +1,26 @@ -'use strict'; +import { jsonp as jsonpBody } from 'jsonp-body'; +import { Context } from '@eggjs/core'; +import { JSONP_CONFIG } from '../../lib/private_key.js'; -const jsonpBody = require('jsonp-body'); -const { JSONP_CONFIG } = require('../../lib/private_key'); - -module.exports = { +export default class JSONPContext extends Context { /** * detect if response should be jsonp */ get acceptJSONP() { - return !!(this[JSONP_CONFIG] && this[JSONP_CONFIG].jsonpFunction); - }, + const jsonpConfig = Reflect.get(this, JSONP_CONFIG) as any; + return !!(jsonpConfig?.jsonpFunction); + } /** * JSONP wrap body function * Set jsonp response wrap function, other plugin can use it. * If not necessary, please don't use this method in your application code. - * @param {Object} body respones body + * @param {Object} body response body * @private */ - createJsonpBody(body) { - const jsonpConfig = this[JSONP_CONFIG]; - if (!jsonpConfig || !jsonpConfig.jsonpFunction) { + createJsonpBody(body: any) { + const jsonpConfig = Reflect.get(this, JSONP_CONFIG) as any; + if (!jsonpConfig?.jsonpFunction) { this.body = body; return; } @@ -30,5 +30,5 @@ module.exports = { body = body === undefined ? null : body; // protect from jsonp xss this.body = jsonpBody(body, jsonpConfig.jsonpFunction, jsonpConfig.options); - }, -}; + } +} diff --git a/src/config/config.default.ts b/src/config/config.default.ts new file mode 100644 index 0000000..e9f689f --- /dev/null +++ b/src/config/config.default.ts @@ -0,0 +1,10 @@ +import type { JSONPConfig } from '../types.js'; + +export default { + jsonp: { + limit: 50, + callback: [ '_callback', 'callback' ], + csrf: false, + whiteList: undefined, + } as JSONPConfig, +}; diff --git a/src/error/JSONPForbiddenReferrerError.ts b/src/error/JSONPForbiddenReferrerError.ts new file mode 100644 index 0000000..d5d0cab --- /dev/null +++ b/src/error/JSONPForbiddenReferrerError.ts @@ -0,0 +1,12 @@ +export class JSONPForbiddenReferrerError extends Error { + referrer: string; + status: number; + + constructor(message: string, referrer: string, status: number) { + super(message); + this.name = this.constructor.name; + this.referrer = referrer; + this.status = status; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ce5fb25 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +import './types.js'; diff --git a/src/lib/private_key.ts b/src/lib/private_key.ts new file mode 100644 index 0000000..56875a9 --- /dev/null +++ b/src/lib/private_key.ts @@ -0,0 +1 @@ +export const JSONP_CONFIG = Symbol('jsonp#config'); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..79ff332 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,55 @@ +import type { MiddlewareFunc } from '@eggjs/core'; + +/** + * jsonp options + * @member Config#jsonp + */ +export interface JSONPConfig { + /** + * jsonp callback methods key, default to `['_callback', 'callback' ]` + */ + callback: string[] | string; + /** + * callback method name's max length, default to `50` + */ + limit: number; + /** + * enable csrf check or not, default to `false` + */ + csrf: boolean; + /** + * referrer white list, default to `undefined` + */ + whiteList?: string | RegExp | (string | RegExp)[]; +} + +declare module '@eggjs/core' { + // add EggAppConfig overrides types + interface EggAppConfig { + jsonp: JSONPConfig; + } + + interface Context { + /** + * detect if response should be jsonp + */ + acceptJSONP: boolean; + /** + * JSONP wrap body function + * Set jsonp response wrap function, other plugin can use it. + * If not necessary, please don't use this method in your application code. + * @param {Object} body response body + * @private + */ + createJsonpBody(body: any): void; + } + + interface EggCore { + /** + * return a middleware to enable jsonp response. + * will do some security check inside. + * @public + */ + jsonp(initOptions?: Partial): MiddlewareFunc; + } +} diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts new file mode 100644 index 0000000..53c65c7 --- /dev/null +++ b/src/typings/index.d.ts @@ -0,0 +1,4 @@ +// make sure to import egg typings and let typescript know about it +// @see https://github.com/whxaxes/blog/issues/11 +// and https://www.typescriptlang.org/docs/handbook/declaration-merging.html +import 'egg'; diff --git a/test/fixtures/jsonp-test/app/controller/jsonp.js b/test/fixtures/jsonp-test/app/controller/jsonp.js index 5fde79f..28993ae 100644 --- a/test/fixtures/jsonp-test/app/controller/jsonp.js +++ b/test/fixtures/jsonp-test/app/controller/jsonp.js @@ -1,11 +1,8 @@ -'use strict'; - exports.index = ctx => { ctx.body = { foo: 'bar' }; }; -exports.empty = function*() {}; - +exports.empty = function() {}; exports.mark = ctx => { ctx.body = { jsonpFunction: ctx.acceptJSONP }; diff --git a/test/fixtures/jsonp-test/app/router.js b/test/fixtures/jsonp-test/app/router.js index 765e6ab..cb8a302 100644 --- a/test/fixtures/jsonp-test/app/router.js +++ b/test/fixtures/jsonp-test/app/router.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = app => { app.get('/default', app.jsonp(), 'jsonp.index'); app.get('/empty', app.jsonp(), 'jsonp.empty'); @@ -11,11 +9,11 @@ module.exports = app => { app.get('/csrf', app.jsonp({ csrf: true }), 'jsonp.index'); app.get('/both', app.jsonp({ csrf: true, whiteList: 'test.com' }), 'jsonp.index'); app.get('/mark', app.jsonp(), 'jsonp.mark'); - app.get('/error', function*(next) { + app.get('/error', async (ctx, next) => { try { - yield next; + await next(); } catch (error) { - this.createJsonpBody({ msg: error.message }); + ctx.createJsonpBody({ msg: error.message }); } }, app.jsonp(), 'jsonp.error'); }; diff --git a/test/fixtures/jsonp-test/config/config.default.js b/test/fixtures/jsonp-test/config/config.default.js index 7ed7a03..fee1067 100644 --- a/test/fixtures/jsonp-test/config/config.default.js +++ b/test/fixtures/jsonp-test/config/config.default.js @@ -1,7 +1,14 @@ -'use strict'; - module.exports = { keys: 'keys', jsonp: { }, + logger: { + consoleLevel: 'NONE', + level: 'NONE', + coreLogger: { + consoleLevel: 'NONE', + level: 'NONE', + }, + disableConsoleAfterReady: true, + }, }; diff --git a/test/jsonp.test.js b/test/jsonp.test.ts similarity index 65% rename from test/jsonp.test.js rename to test/jsonp.test.ts index 8034496..55f2aad 100644 --- a/test/jsonp.test.js +++ b/test/jsonp.test.ts @@ -1,9 +1,8 @@ -'use strict'; +import { strict as assert } from 'node:assert'; +import { mm, MockApplication } from '@eggjs/mock'; -const mm = require('egg-mock'); - -describe('test/jsonp.test.js', () => { - let app; +describe('test/jsonp.test.ts', () => { + let app: MockApplication; before(() => { app = mm.app({ baseDir: 'jsonp-test', @@ -14,56 +13,60 @@ describe('test/jsonp.test.js', () => { after(() => app.close()); afterEach(mm.restore); - it('should support json', function* () { - yield app.httpRequest() + it('should access acceptJSONP return false by default', () => { + assert.equal(app.mockContext().acceptJSONP, false); + }); + + it('should support json', async () => { + await app.httpRequest() .get('/default') .expect(200) .expect({ foo: 'bar' }); }); - it('should support jsonp', function* () { - yield app.httpRequest() + it('should support jsonp', async () => { + await app.httpRequest() .get('/default?callback=fn') .expect(200) .expect('/**/ typeof fn === \'function\' && fn({"foo":"bar"});'); }); - it('should support _callback', function* () { - yield app.httpRequest() + it('should support _callback', async () => { + await app.httpRequest() .get('/default?_callback=fn') .expect(200) .expect('/**/ typeof fn === \'function\' && fn({"foo":"bar"});'); }); - it('should support jsonp if response is empty', function* () { - yield app.httpRequest() + it('should support jsonp if response is empty', async () => { + await app.httpRequest() .get('/empty?callback=fn') .expect(200) .expect('/**/ typeof fn === \'function\' && fn(null);'); }); - it('should not support jsonp if not use jsonp middleware', function* () { - yield app.httpRequest() + it('should not support jsonp if not use jsonp middleware', async () => { + await app.httpRequest() .get('/disable?_callback=fn') .expect(200) .expect({ foo: 'bar' }); }); - it('should not support cutom callback name', function* () { - yield app.httpRequest() + it('should not support custom callback name', async () => { + await app.httpRequest() .get('/fn?fn=fn') .expect(200) .expect('/**/ typeof fn === \'function\' && fn({"foo":"bar"});'); }); - it('should not pass csrf', function* () { - yield app.httpRequest() + it('should not pass csrf', async () => { + await app.httpRequest() .get('/csrf') .expect(403); }); - it('should pass csrf with cookie', function* () { - yield app.httpRequest() + it('should pass csrf with cookie', async () => { + await app.httpRequest() .get('/csrf') .set('cookie', 'csrfToken=token;') .set('x-csrf-token', 'token') @@ -71,8 +74,8 @@ describe('test/jsonp.test.js', () => { .expect({ foo: 'bar' }); }); - it('should pass csrf with cookie and support jsonp', function* () { - yield app.httpRequest() + it('should pass csrf with cookie and support jsonp', async () => { + await app.httpRequest() .get('/csrf') .set('cookie', 'csrfToken=token;') .set('x-csrf-token', 'token') @@ -80,86 +83,86 @@ describe('test/jsonp.test.js', () => { .expect({ foo: 'bar' }); }); - it('should pass referrer white list check with subdomain', function* () { - yield app.httpRequest() + it('should pass referrer white list check with subdomain', async () => { + await app.httpRequest() .get('/referrer/subdomain') .set('referrer', 'http://test.com/') .expect(200) .expect({ foo: 'bar' }); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/subdomain') .set('referrer', 'http://sub.test.com/') .expect(200) .expect({ foo: 'bar' }); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/subdomain') .set('referrer', 'https://sub.sub.test.com/') .expect(200) .expect({ foo: 'bar' }); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/subdomain') .set('referrer', 'https://sub.sub.test1.com/') .expect(403) .expect(/jsonp request security validate failed/); }); - it('should pass referrer white list with domain', function* () { - yield app.httpRequest() + it('should pass referrer white list with domain', async () => { + await app.httpRequest() .get('/referrer/equal') .set('referrer', 'http://test.com/') .expect(200) .expect({ foo: 'bar' }); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/equal') .set('referrer', 'https://test.com/') .expect(200) .expect({ foo: 'bar' }); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/equal') .set('referrer', 'https://sub.sub.test.com/') .expect(403) .expect(/jsonp request security validate failed/); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/equal') .set('referrer', 'https://sub.sub.test1.com/') .expect(403) .expect(/jsonp request security validate failed/); }); - it('should pass referrer white array and regexp', function* () { - yield app.httpRequest() + it('should pass referrer white array and regexp', async () => { + await app.httpRequest() .get('/referrer/regexp') .set('referrer', 'http://test.com/') .expect(200) .expect({ foo: 'bar' }); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/regexp') .set('referrer', 'https://foo.com/') .expect(200) .expect({ foo: 'bar' }); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/regexp') .set('referrer', 'https://sub.sub.test.com/') .expect(403) .expect(/jsonp request security validate failed/); - yield app.httpRequest() + await app.httpRequest() .get('/referrer/regexp') .set('referrer', 'https://sub.sub.test1.com/') .expect(403) .expect(/jsonp request security validate failed/); }); - it('should pass when pass csrf but not hit referrer white list', function* () { - yield app.httpRequest() + it('should pass when pass csrf but not hit referrer white list', async () => { + await app.httpRequest() .get('/both') .set('cookie', 'csrfToken=token;') .set('x-csrf-token', 'token') @@ -167,45 +170,45 @@ describe('test/jsonp.test.js', () => { .expect({ foo: 'bar' }); }); - it('should pass when not pass csrf but hit referrer white list', function* () { - yield app.httpRequest() + it('should pass when not pass csrf but hit referrer white list', async () => { + await app.httpRequest() .get('/both') .set('referrer', 'https://test.com/') .expect(200) .expect({ foo: 'bar' }); }); - it('should 403 when not pass csrf and not hit referrer white list', function* () { - yield app.httpRequest() + it('should 403 when not pass csrf and not hit referrer white list', async () => { + await app.httpRequest() .get('/both') .expect(403) .expect(/jsonp request security validate failed/); }); - it('should 403 when not pass csrf and referrer illegal', function* () { - yield app.httpRequest() + it('should 403 when not pass csrf and referrer illegal', async () => { + await app.httpRequest() .get('/both') .set('referrer', '/hello') .expect(403) .expect(/jsonp request security validate failed/); }); - it('should pass and return is a jsonp function', function* () { - yield app.httpRequest() + it('should pass and return is a jsonp function', async () => { + await app.httpRequest() .get('/mark?_callback=fn') .expect(200) .expect('/**/ typeof fn === \'function\' && fn({"jsonpFunction":true});'); }); - it('should pass and return is not a jsonp function', function* () { - yield app.httpRequest() + it('should pass and return is not a jsonp function', async () => { + await app.httpRequest() .get('/mark') .expect(200) .expect({ jsonpFunction: false }); }); - it('should pass and return error message', function* () { - yield app.httpRequest() + it('should pass and return error message', async () => { + await app.httpRequest() .get('/error?_callback=fn') .expect(200) .expect('/**/ typeof fn === \'function\' && fn({"msg":"jsonpFunction is error"});'); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff41b73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@eggjs/tsconfig", + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +}