├── src ├── index.ts ├── lib │ └── private_key.ts ├── typings │ └── index.d.ts ├── config │ └── config.default.ts ├── error │ └── JSONPForbiddenReferrerError.ts ├── app │ └── extend │ │ ├── context.ts │ │ └── application.ts └── types.ts ├── .eslintignore ├── test ├── fixtures │ └── jsonp-test │ │ ├── package.json │ │ ├── config │ │ └── config.default.js │ │ └── app │ │ ├── controller │ │ └── jsonp.js │ │ └── router.js └── jsonp.test.ts ├── .eslintrc ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ ├── release.yml │ ├── nodejs.yml │ └── pkg.pr.new.yml ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | import './types.js'; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | test/fixtures 2 | coverage 3 | -------------------------------------------------------------------------------- /src/lib/private_key.ts: -------------------------------------------------------------------------------- 1 | export const JSONP_CONFIG = Symbol('jsonp#config'); 2 | -------------------------------------------------------------------------------- /test/fixtures/jsonp-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsonp-test", 3 | "version": "0.0.1" 4 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint-config-egg/typescript", 4 | "eslint-config-egg/lib/rules/enforce-node-prefix" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | npm-debug.log 3 | node_modules/ 4 | coverage/ 5 | test/fixtures/**/run 6 | .DS_Store 7 | .tshy* 8 | .eslintcache 9 | dist 10 | package-lock.json 11 | .package-lock.json 12 | -------------------------------------------------------------------------------- /src/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | // make sure to import egg typings and let typescript know about it 2 | // @see https://github.com/whxaxes/blog/issues/11 3 | // and https://www.typescriptlang.org/docs/handbook/declaration-merging.html 4 | import 'egg'; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@eggjs/tsconfig", 3 | "compilerOptions": { 4 | "strict": true, 5 | "noImplicitAny": true, 6 | "target": "ES2022", 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/config/config.default.ts: -------------------------------------------------------------------------------- 1 | import type { JSONPConfig } from '../types.js'; 2 | 3 | export default { 4 | jsonp: { 5 | limit: 50, 6 | callback: [ '_callback', 'callback' ], 7 | csrf: false, 8 | whiteList: undefined, 9 | } as JSONPConfig, 10 | }; 11 | -------------------------------------------------------------------------------- /test/fixtures/jsonp-test/config/config.default.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | keys: 'keys', 3 | jsonp: { 4 | }, 5 | logger: { 6 | consoleLevel: 'NONE', 7 | level: 'NONE', 8 | coreLogger: { 9 | consoleLevel: 'NONE', 10 | level: 'NONE', 11 | }, 12 | disableConsoleAfterReady: true, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | release: 9 | name: Node.js 10 | uses: eggjs/github-actions/.github/workflows/node-release.yml@master 11 | secrets: 12 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 13 | GIT_TOKEN: ${{ secrets.GIT_TOKEN }} 14 | -------------------------------------------------------------------------------- /test/fixtures/jsonp-test/app/controller/jsonp.js: -------------------------------------------------------------------------------- 1 | exports.index = ctx => { 2 | ctx.body = { foo: 'bar' }; 3 | }; 4 | 5 | exports.empty = function() {}; 6 | 7 | exports.mark = ctx => { 8 | ctx.body = { jsonpFunction: ctx.acceptJSONP }; 9 | }; 10 | 11 | exports.error = async () => { 12 | throw new Error('jsonpFunction is error'); 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | Job: 11 | name: Node.js 12 | uses: node-modules/github-actions/.github/workflows/node-test.yml@master 13 | with: 14 | version: '18.19.0, 20, 22' 15 | secrets: 16 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 17 | -------------------------------------------------------------------------------- /src/error/JSONPForbiddenReferrerError.ts: -------------------------------------------------------------------------------- 1 | export class JSONPForbiddenReferrerError extends Error { 2 | referrer: string; 3 | status: number; 4 | 5 | constructor(message: string, referrer: string, status: number) { 6 | super(message); 7 | this.name = this.constructor.name; 8 | this.referrer = referrer; 9 | this.status = status; 10 | Error.captureStackTrace(this, this.constructor); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/pkg.pr.new.yml: -------------------------------------------------------------------------------- 1 | name: Publish Any Commit 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v4 11 | 12 | - run: corepack enable 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | 17 | - name: Install dependencies 18 | run: npm install 19 | 20 | - name: Build 21 | run: npm run prepublishOnly --if-present 22 | 23 | - run: npx pkg-pr-new publish 24 | -------------------------------------------------------------------------------- /test/fixtures/jsonp-test/app/router.js: -------------------------------------------------------------------------------- 1 | module.exports = app => { 2 | app.get('/default', app.jsonp(), 'jsonp.index'); 3 | app.get('/empty', app.jsonp(), 'jsonp.empty'); 4 | app.get('/disable', 'jsonp.index'); 5 | app.get('/fn', app.jsonp({ callback: 'fn' }), 'jsonp.index'); 6 | app.get('/referrer/subdomain', app.jsonp({ whiteList: '.test.com' }), 'jsonp.index'); 7 | app.get('/referrer/equal', app.jsonp({ whiteList: 'test.com' }), 'jsonp.index'); 8 | app.get('/referrer/regexp', app.jsonp({ whiteList: [/https?:\/\/test\.com\//, /https?:\/\/foo\.com\//] }), 'jsonp.index'); 9 | app.get('/csrf', app.jsonp({ csrf: true }), 'jsonp.index'); 10 | app.get('/both', app.jsonp({ csrf: true, whiteList: 'test.com' }), 'jsonp.index'); 11 | app.get('/mark', app.jsonp(), 'jsonp.mark'); 12 | app.get('/error', async (ctx, next) => { 13 | try { 14 | await next(); 15 | } catch (error) { 16 | ctx.createJsonpBody({ msg: error.message }); 17 | } 18 | }, app.jsonp(), 'jsonp.error'); 19 | }; 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Alibaba Group Holding Limited and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/extend/context.ts: -------------------------------------------------------------------------------- 1 | import { jsonp as jsonpBody } from 'jsonp-body'; 2 | import { Context } from '@eggjs/core'; 3 | import { JSONP_CONFIG } from '../../lib/private_key.js'; 4 | 5 | export default class JSONPContext extends Context { 6 | /** 7 | * detect if response should be jsonp 8 | */ 9 | get acceptJSONP() { 10 | const jsonpConfig = Reflect.get(this, JSONP_CONFIG) as any; 11 | return !!(jsonpConfig?.jsonpFunction); 12 | } 13 | 14 | /** 15 | * JSONP wrap body function 16 | * Set jsonp response wrap function, other plugin can use it. 17 | * If not necessary, please don't use this method in your application code. 18 | * @param {Object} body response body 19 | * @private 20 | */ 21 | createJsonpBody(body: any) { 22 | const jsonpConfig = Reflect.get(this, JSONP_CONFIG) as any; 23 | if (!jsonpConfig?.jsonpFunction) { 24 | this.body = body; 25 | return; 26 | } 27 | 28 | this.set('x-content-type-options', 'nosniff'); 29 | this.type = 'js'; 30 | body = body === undefined ? null : body; 31 | // protect from jsonp xss 32 | this.body = jsonpBody(body, jsonpConfig.jsonpFunction, jsonpConfig.options); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareFunc } from '@eggjs/core'; 2 | 3 | /** 4 | * jsonp options 5 | * @member Config#jsonp 6 | */ 7 | export interface JSONPConfig { 8 | /** 9 | * jsonp callback methods key, default to `['_callback', 'callback' ]` 10 | */ 11 | callback: string[] | string; 12 | /** 13 | * callback method name's max length, default to `50` 14 | */ 15 | limit: number; 16 | /** 17 | * enable csrf check or not, default to `false` 18 | */ 19 | csrf: boolean; 20 | /** 21 | * referrer white list, default to `undefined` 22 | */ 23 | whiteList?: string | RegExp | (string | RegExp)[]; 24 | } 25 | 26 | declare module '@eggjs/core' { 27 | // add EggAppConfig overrides types 28 | interface EggAppConfig { 29 | jsonp: JSONPConfig; 30 | } 31 | 32 | interface Context { 33 | /** 34 | * detect if response should be jsonp 35 | */ 36 | acceptJSONP: boolean; 37 | /** 38 | * JSONP wrap body function 39 | * Set jsonp response wrap function, other plugin can use it. 40 | * If not necessary, please don't use this method in your application code. 41 | * @param {Object} body response body 42 | * @private 43 | */ 44 | createJsonpBody(body: any): void; 45 | } 46 | 47 | interface EggCore { 48 | /** 49 | * return a middleware to enable jsonp response. 50 | * will do some security check inside. 51 | * @public 52 | */ 53 | jsonp(initOptions?: Partial): MiddlewareFunc; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eggjs/jsonp", 3 | "version": "3.0.0", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "jsonp support for egg", 8 | "eggPlugin": { 9 | "name": "jsonp", 10 | "optionalDependencies": [ 11 | "security" 12 | ], 13 | "exports": { 14 | "import": "./dist/esm", 15 | "require": "./dist/commonjs", 16 | "typescript": "./src" 17 | } 18 | }, 19 | "keywords": [ 20 | "egg", 21 | "egg-plugin", 22 | "jsonp", 23 | "security" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/eggjs/jsonp.git" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/eggjs/egg/issues" 31 | }, 32 | "homepage": "https://github.com/eggjs/jsonp#readme", 33 | "author": "dead-horse", 34 | "license": "MIT", 35 | "engines": { 36 | "node": ">= 18.19.0" 37 | }, 38 | "dependencies": { 39 | "@eggjs/core": "^6.2.13", 40 | "jsonp-body": "^2.0.0" 41 | }, 42 | "devDependencies": { 43 | "@arethetypeswrong/cli": "^0.17.1", 44 | "@eggjs/bin": "7", 45 | "@eggjs/mock": "6", 46 | "@eggjs/tsconfig": "1", 47 | "@types/mocha": "10", 48 | "@types/node": "22", 49 | "egg": "4", 50 | "eslint": "8", 51 | "eslint-config-egg": "14", 52 | "rimraf": "6", 53 | "tshy": "3", 54 | "tshy-after": "1", 55 | "typescript": "5" 56 | }, 57 | "scripts": { 58 | "lint": "eslint --cache src test --ext .ts", 59 | "pretest": "npm run clean && npm run lint -- --fix", 60 | "test": "egg-bin test", 61 | "preci": "npm run clean && npm run lint", 62 | "ci": "egg-bin cov", 63 | "postci": "npm run prepublishOnly && npm run clean", 64 | "clean": "rimraf dist", 65 | "prepublishOnly": "tshy && tshy-after && attw --pack" 66 | }, 67 | "type": "module", 68 | "tshy": { 69 | "exports": { 70 | ".": "./src/index.ts", 71 | "./package.json": "./package.json" 72 | } 73 | }, 74 | "exports": { 75 | ".": { 76 | "import": { 77 | "types": "./dist/esm/index.d.ts", 78 | "default": "./dist/esm/index.js" 79 | }, 80 | "require": { 81 | "types": "./dist/commonjs/index.d.ts", 82 | "default": "./dist/commonjs/index.js" 83 | } 84 | }, 85 | "./package.json": "./package.json" 86 | }, 87 | "files": [ 88 | "dist", 89 | "src" 90 | ], 91 | "types": "./dist/commonjs/index.d.ts", 92 | "main": "./dist/commonjs/index.js", 93 | "module": "./dist/esm/index.js" 94 | } 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.0.0](https://github.com/eggjs/jsonp/compare/v2.0.0...v3.0.0) (2025-01-11) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * drop Node.js < 18.19.0 support 9 | 10 | part of https://github.com/eggjs/egg/issues/3644 11 | 12 | https://github.com/eggjs/egg/issues/5257 13 | 14 | 16 | ## Summary by CodeRabbit 17 | 18 | ## Release Notes 19 | 20 | - **New Features** 21 | - Added TypeScript support for the JSONP plugin 22 | - Modernized project structure with ES module syntax 23 | - Enhanced type definitions and configuration 24 | - Introduced new GitHub Actions workflows for CI/CD 25 | - Added a new class for JSONP error handling 26 | 27 | - **Breaking Changes** 28 | - Renamed package from `egg-jsonp` to `@eggjs/jsonp` 29 | - Dropped support for Node.js versions below 18.19.0 30 | - Refactored configuration and middleware approach 31 | 32 | - **Improvements** 33 | - Updated GitHub Actions workflows for CI/CD 34 | - Improved security checks for JSONP requests 35 | - Added more robust error handling 36 | - Enhanced logging configuration 37 | 38 | - **Dependency Updates** 39 | - Updated core dependencies 40 | - Migrated to modern TypeScript tooling 41 | 42 | 43 | ### Features 44 | 45 | * support cjs and esm both by tshy ([#12](https://github.com/eggjs/jsonp/issues/12)) ([9136768](https://github.com/eggjs/jsonp/commit/9136768ba518dcec765f86af3e7259d131b6917b)) 46 | 47 | 2.0.0 / 2017-11-11 48 | ================== 49 | 50 | **others** 51 | * [[`a9cadba`](http://github.com/eggjs/egg-jsonp/commit/a9cadba740dc54b9c3dd0593495b66b5a98383e8)] - refactor: use async function and support egg@2 (#10) (Yiyu He <>) 52 | 53 | 1.2.2 / 2017-11-10 54 | ================== 55 | 56 | **fixes** 57 | * [[`d14d2d6`](http://github.com/eggjs/egg-jsonp/commit/d14d2d6aa1cdc50ff084f801fa741221667ee577)] - fix: rename to createJsonpBody (#9) (Yiyu He <>) 58 | 59 | **others** 60 | * [[`f7137a0`](http://github.com/eggjs/egg-jsonp/commit/f7137a011b202882fbfd48a4ee434031a9b950d2)] - chore: add pkgfiles check in ci (dead-horse <>) 61 | 62 | 1.2.1 / 2017-10-11 63 | ================== 64 | 65 | **fixes** 66 | * [[`a19f450`](http://github.com/eggjs/egg-jsonp/commit/a19f45089ed8229a3ee0099a730a2be9ea57b114)] - fix: add lib into files (dead-horse <>) 67 | 68 | 1.2.0 / 2017-10-11 69 | ================== 70 | 71 | **features** 72 | * [[`ee98948`](http://github.com/eggjs/egg-jsonp/commit/ee9894834ed8de081b26680a58506896d736cb61)] - feat: add acceptJSONP and open jsonp wrap function (#8) (Gao Peng <>) 73 | 74 | 1.1.2 / 2017-07-21 75 | ================== 76 | 77 | * fix: should not throw when referrer illegal (#5) 78 | 79 | 1.1.1 / 2017-06-04 80 | ================== 81 | 82 | * docs: fix License url (#4) 83 | 84 | 1.1.0 / 2017-06-01 85 | ================== 86 | 87 | * test: test on node 8 88 | * feat: support _callback and callback 89 | 90 | 1.0.0 / 2017-01-23 91 | ================== 92 | 93 | * fix: refine jsonp (#1) 94 | * feat: init jsonp 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @eggjs/jsonp 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![Node.js CI](https://github.com/eggjs/jsonp/actions/workflows/nodejs.yml/badge.svg)](https://github.com/eggjs/jsonp/actions/workflows/nodejs.yml) 5 | [![Test coverage][codecov-image]][codecov-url] 6 | [![Known Vulnerabilities][snyk-image]][snyk-url] 7 | [![npm download][download-image]][download-url] 8 | [![Node.js Version](https://img.shields.io/node/v/@eggjs/jsonp.svg?style=flat)](https://nodejs.org/en/download/) 9 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) 10 | 11 | [npm-image]: https://img.shields.io/npm/v/@eggjs/jsonp.svg?style=flat-square 12 | [npm-url]: https://npmjs.org/package/@eggjs/jsonp 13 | [codecov-image]: https://img.shields.io/codecov/c/github/eggjs/jsonp.svg?style=flat-square 14 | [codecov-url]: https://codecov.io/github/eggjs/jsonp?branch=master 15 | [snyk-image]: https://snyk.io/test/npm/@eggjs/jsonp/badge.svg?style=flat-square 16 | [snyk-url]: https://snyk.io/test/npm/@eggjs/jsonp 17 | [download-image]: https://img.shields.io/npm/dm/@eggjs/jsonp.svg?style=flat-square 18 | [download-url]: https://npmjs.org/package/@eggjs/jsonp 19 | 20 | An egg plugin for jsonp support. 21 | 22 | ## Requirements 23 | 24 | - egg >= 4.x 25 | 26 | ## Install 27 | 28 | ```bash 29 | npm i @eggjs/jsonp 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```ts 35 | // {app_root}/config/plugin.ts 36 | 37 | export default { 38 | jsonp: { 39 | enable: true, 40 | package: '@eggjs/jsonp', 41 | }, 42 | }; 43 | ``` 44 | 45 | ## Configuration 46 | 47 | - {String|Array} callback - jsonp callback method key, default to `[ '_callback', 'callback' ]` 48 | - {Number} limit - callback method name's max length, default to `50` 49 | - {Boolean} csrf - enable csrf check or not. default to false 50 | - {String|RegExp|Array} whiteList - referrer white list 51 | 52 | if whiteList's type is `RegExp`, referrer must match `whiteList`, pay attention to the first `^` and last `/`. 53 | 54 | ```ts 55 | export default { 56 | jsonp: { 57 | whiteList: /^https?:\/\/test.com\//, 58 | }, 59 | }; 60 | 61 | // matchs referrer: 62 | // https://test.com/hello 63 | // http://test.com/ 64 | ``` 65 | 66 | if whiteList's type is `String` and starts with `.`: 67 | 68 | ```ts 69 | export default { 70 | jsonp: { 71 | whiteList: '.test.com', 72 | }, 73 | }; 74 | 75 | // matchs domain test.com: 76 | // https://test.com/hello 77 | // http://test.com/ 78 | 79 | // matchs subdomain 80 | // https://sub.test.com/hello 81 | // http://sub.sub.test.com/ 82 | ``` 83 | 84 | if whiteList's type is `String` and not starts with `.`: 85 | 86 | ```ts 87 | export default { 88 | jsonp: { 89 | whiteList: 'sub.test.com', 90 | }, 91 | }; 92 | 93 | // only matchs domain sub.test.com: 94 | // https://sub.test.com/hello 95 | // http://sub.test.com/ 96 | ``` 97 | 98 | whiteList also can be an array: 99 | 100 | ```ts 101 | export default { 102 | jsonp: { 103 | whiteList: [ '.foo.com', '.bar.com' ], 104 | }, 105 | }; 106 | ``` 107 | 108 | see [config/config.default.ts](https://github.com/eggjs/jsonp/blob/master/src/config/config.default.ts) for more detail. 109 | 110 | ## API 111 | 112 | - ctx.acceptJSONP - detect if response should be jsonp, readonly 113 | 114 | ## Example 115 | 116 | In `app/router.ts` 117 | 118 | ```ts 119 | // Create once and use in any router you want to support jsonp. 120 | const jsonp = app.jsonp(); 121 | 122 | app.get('/default', jsonp, 'jsonp.index'); 123 | app.get('/another', jsonp, 'jsonp.another'); 124 | 125 | // Customize by create another jsonp middleware with specific configurations. 126 | app.get('/customize', app.jsonp({ callback: 'fn' }), 'jsonp.customize'); 127 | ``` 128 | 129 | ## Questions & Suggestions 130 | 131 | Please open an issue [here](https://github.com/eggjs/egg/issues). 132 | 133 | ## License 134 | 135 | [MIT](LICENSE) 136 | 137 | ## Contributors 138 | 139 | [![Contributors](https://contrib.rocks/image?repo=eggjs/jsonp)](https://github.com/eggjs/jsonp/graphs/contributors) 140 | 141 | Made with [contributors-img](https://contrib.rocks). 142 | -------------------------------------------------------------------------------- /src/app/extend/application.ts: -------------------------------------------------------------------------------- 1 | import { debuglog } from 'node:util'; 2 | import { parse as urlParse, type UrlWithStringQuery } from 'node:url'; 3 | import type { ParsedUrlQuery } from 'node:querystring'; 4 | import { EggCore, type MiddlewareFunc } from '@eggjs/core'; 5 | import { JSONP_CONFIG } from '../../lib/private_key.js'; 6 | import { JSONPConfig } from '../../types.js'; 7 | import { JSONPForbiddenReferrerError } from '../../error/JSONPForbiddenReferrerError.js'; 8 | import JSONPContext from './context.js'; 9 | 10 | const debug = debuglog('@egg/jsonp/app/extend/application'); 11 | 12 | export default class JSONPApplication extends EggCore { 13 | /** 14 | * return a middleware to enable jsonp response. 15 | * will do some security check inside. 16 | * @public 17 | */ 18 | jsonp(initOptions: Partial = {}): MiddlewareFunc { 19 | const options = { 20 | ...this.config.jsonp, 21 | ...initOptions, 22 | } as JSONPConfig & { callback: string[] }; 23 | if (!Array.isArray(options.callback)) { 24 | options.callback = [ options.callback ]; 25 | } 26 | 27 | const csrfEnable = this.plugins.security && this.plugins.security.enable // security enable 28 | && this.config.security.csrf && this.config.security.csrf.enable !== false // csrf enable 29 | && options.csrf; // jsonp csrf enabled 30 | 31 | const validateReferrer = options.whiteList && createValidateReferer(options.whiteList); 32 | 33 | if (!csrfEnable && !validateReferrer) { 34 | this.coreLogger.warn('[@eggjs/jsonp] SECURITY WARNING!! csrf check and referrer check are both closed!'); 35 | } 36 | /** 37 | * jsonp request security check, pass if 38 | * 39 | * 1. hit referrer white list 40 | * 2. or pass csrf check 41 | * 3. both check are disabled 42 | * 43 | * @param {Context} ctx request context 44 | */ 45 | function securityAssert(ctx: JSONPContext) { 46 | // all disabled. don't need check 47 | if (!csrfEnable && !validateReferrer) return; 48 | 49 | // pass referrer check 50 | const referrer = ctx.get('referrer'); 51 | if (validateReferrer && validateReferrer(referrer)) return; 52 | if (csrfEnable && validateCsrf(ctx)) return; 53 | 54 | throw new JSONPForbiddenReferrerError( 55 | 'jsonp request security validate failed', 56 | referrer, 57 | 403); 58 | } 59 | 60 | return async function jsonp(ctx: JSONPContext, next) { 61 | const jsonpFunction = getJsonpFunction(ctx.query, options.callback); 62 | 63 | ctx[JSONP_CONFIG] = { 64 | jsonpFunction, 65 | options, 66 | }; 67 | 68 | // before handle request, must do some security checks 69 | securityAssert(ctx); 70 | 71 | await next(); 72 | 73 | // generate jsonp body 74 | ctx.createJsonpBody(ctx.body); 75 | }; 76 | } 77 | } 78 | 79 | function createValidateReferer(whiteList: Required['whiteList']) { 80 | if (!Array.isArray(whiteList)) { 81 | whiteList = [ whiteList ]; 82 | } 83 | 84 | return (referrer: string) => { 85 | let parsed: UrlWithStringQuery | undefined; 86 | for (const rule of whiteList) { 87 | if (rule instanceof RegExp) { 88 | if (rule.test(referrer)) { 89 | // regexp(/^https?:\/\/github.com\//): test the referrer with rule 90 | return true; 91 | } 92 | continue; 93 | } 94 | 95 | parsed = parsed ?? urlParse(referrer); 96 | const hostname = parsed.hostname || ''; 97 | 98 | // check if referrer's hostname match the string rule 99 | if (rule[0] === '.' && 100 | (hostname.endsWith(rule) || hostname === rule.slice(1))) { 101 | // string start with `.`(.github.com): referrer's hostname must ends with rule 102 | return true; 103 | } else if (hostname === rule) { 104 | // string not start with `.`(github.com): referrer's hostname must strict equal to rule 105 | return true; 106 | } 107 | } 108 | 109 | // no rule matched 110 | return false; 111 | }; 112 | } 113 | 114 | function validateCsrf(ctx: any) { 115 | try { 116 | // TODO(fengmk2): remove this when @eggjs/security support ctx.assertCsrf type define 117 | ctx.assertCsrf(); 118 | return true; 119 | } catch (err) { 120 | debug('validate csrf failed: %s', err); 121 | return false; 122 | } 123 | } 124 | 125 | function getJsonpFunction(query: ParsedUrlQuery, callbacks: string[]) { 126 | for (const callback of callbacks) { 127 | if (query[callback]) { 128 | return query[callback] as string; 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/jsonp.test.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert'; 2 | import { mm, MockApplication } from '@eggjs/mock'; 3 | 4 | describe('test/jsonp.test.ts', () => { 5 | let app: MockApplication; 6 | before(() => { 7 | app = mm.app({ 8 | baseDir: 'jsonp-test', 9 | }); 10 | return app.ready(); 11 | }); 12 | 13 | after(() => app.close()); 14 | afterEach(mm.restore); 15 | 16 | it('should access acceptJSONP return false by default', () => { 17 | assert.equal(app.mockContext().acceptJSONP, false); 18 | }); 19 | 20 | it('should support json', async () => { 21 | await app.httpRequest() 22 | .get('/default') 23 | .expect(200) 24 | .expect({ foo: 'bar' }); 25 | }); 26 | 27 | it('should support jsonp', async () => { 28 | await app.httpRequest() 29 | .get('/default?callback=fn') 30 | .expect(200) 31 | .expect('/**/ typeof fn === \'function\' && fn({"foo":"bar"});'); 32 | }); 33 | 34 | it('should support _callback', async () => { 35 | await app.httpRequest() 36 | .get('/default?_callback=fn') 37 | .expect(200) 38 | .expect('/**/ typeof fn === \'function\' && fn({"foo":"bar"});'); 39 | }); 40 | 41 | it('should support jsonp if response is empty', async () => { 42 | await app.httpRequest() 43 | .get('/empty?callback=fn') 44 | .expect(200) 45 | .expect('/**/ typeof fn === \'function\' && fn(null);'); 46 | }); 47 | 48 | it('should not support jsonp if not use jsonp middleware', async () => { 49 | await app.httpRequest() 50 | .get('/disable?_callback=fn') 51 | .expect(200) 52 | .expect({ foo: 'bar' }); 53 | }); 54 | 55 | it('should not support custom callback name', async () => { 56 | await app.httpRequest() 57 | .get('/fn?fn=fn') 58 | .expect(200) 59 | .expect('/**/ typeof fn === \'function\' && fn({"foo":"bar"});'); 60 | }); 61 | 62 | it('should not pass csrf', async () => { 63 | await app.httpRequest() 64 | .get('/csrf') 65 | .expect(403); 66 | }); 67 | 68 | it('should pass csrf with cookie', async () => { 69 | await app.httpRequest() 70 | .get('/csrf') 71 | .set('cookie', 'csrfToken=token;') 72 | .set('x-csrf-token', 'token') 73 | .expect(200) 74 | .expect({ foo: 'bar' }); 75 | }); 76 | 77 | it('should pass csrf with cookie and support jsonp', async () => { 78 | await app.httpRequest() 79 | .get('/csrf') 80 | .set('cookie', 'csrfToken=token;') 81 | .set('x-csrf-token', 'token') 82 | .expect(200) 83 | .expect({ foo: 'bar' }); 84 | }); 85 | 86 | it('should pass referrer white list check with subdomain', async () => { 87 | await app.httpRequest() 88 | .get('/referrer/subdomain') 89 | .set('referrer', 'http://test.com/') 90 | .expect(200) 91 | .expect({ foo: 'bar' }); 92 | 93 | await app.httpRequest() 94 | .get('/referrer/subdomain') 95 | .set('referrer', 'http://sub.test.com/') 96 | .expect(200) 97 | .expect({ foo: 'bar' }); 98 | 99 | await app.httpRequest() 100 | .get('/referrer/subdomain') 101 | .set('referrer', 'https://sub.sub.test.com/') 102 | .expect(200) 103 | .expect({ foo: 'bar' }); 104 | 105 | await app.httpRequest() 106 | .get('/referrer/subdomain') 107 | .set('referrer', 'https://sub.sub.test1.com/') 108 | .expect(403) 109 | .expect(/jsonp request security validate failed/); 110 | }); 111 | 112 | it('should pass referrer white list with domain', async () => { 113 | await app.httpRequest() 114 | .get('/referrer/equal') 115 | .set('referrer', 'http://test.com/') 116 | .expect(200) 117 | .expect({ foo: 'bar' }); 118 | 119 | await app.httpRequest() 120 | .get('/referrer/equal') 121 | .set('referrer', 'https://test.com/') 122 | .expect(200) 123 | .expect({ foo: 'bar' }); 124 | 125 | await app.httpRequest() 126 | .get('/referrer/equal') 127 | .set('referrer', 'https://sub.sub.test.com/') 128 | .expect(403) 129 | .expect(/jsonp request security validate failed/); 130 | 131 | await app.httpRequest() 132 | .get('/referrer/equal') 133 | .set('referrer', 'https://sub.sub.test1.com/') 134 | .expect(403) 135 | .expect(/jsonp request security validate failed/); 136 | }); 137 | 138 | it('should pass referrer white array and regexp', async () => { 139 | await app.httpRequest() 140 | .get('/referrer/regexp') 141 | .set('referrer', 'http://test.com/') 142 | .expect(200) 143 | .expect({ foo: 'bar' }); 144 | 145 | await app.httpRequest() 146 | .get('/referrer/regexp') 147 | .set('referrer', 'https://foo.com/') 148 | .expect(200) 149 | .expect({ foo: 'bar' }); 150 | 151 | await app.httpRequest() 152 | .get('/referrer/regexp') 153 | .set('referrer', 'https://sub.sub.test.com/') 154 | .expect(403) 155 | .expect(/jsonp request security validate failed/); 156 | 157 | await app.httpRequest() 158 | .get('/referrer/regexp') 159 | .set('referrer', 'https://sub.sub.test1.com/') 160 | .expect(403) 161 | .expect(/jsonp request security validate failed/); 162 | }); 163 | 164 | it('should pass when pass csrf but not hit referrer white list', async () => { 165 | await app.httpRequest() 166 | .get('/both') 167 | .set('cookie', 'csrfToken=token;') 168 | .set('x-csrf-token', 'token') 169 | .expect(200) 170 | .expect({ foo: 'bar' }); 171 | }); 172 | 173 | it('should pass when not pass csrf but hit referrer white list', async () => { 174 | await app.httpRequest() 175 | .get('/both') 176 | .set('referrer', 'https://test.com/') 177 | .expect(200) 178 | .expect({ foo: 'bar' }); 179 | }); 180 | 181 | it('should 403 when not pass csrf and not hit referrer white list', async () => { 182 | await app.httpRequest() 183 | .get('/both') 184 | .expect(403) 185 | .expect(/jsonp request security validate failed/); 186 | }); 187 | 188 | it('should 403 when not pass csrf and referrer illegal', async () => { 189 | await app.httpRequest() 190 | .get('/both') 191 | .set('referrer', '/hello') 192 | .expect(403) 193 | .expect(/jsonp request security validate failed/); 194 | }); 195 | 196 | it('should pass and return is a jsonp function', async () => { 197 | await app.httpRequest() 198 | .get('/mark?_callback=fn') 199 | .expect(200) 200 | .expect('/**/ typeof fn === \'function\' && fn({"jsonpFunction":true});'); 201 | }); 202 | 203 | it('should pass and return is not a jsonp function', async () => { 204 | await app.httpRequest() 205 | .get('/mark') 206 | .expect(200) 207 | .expect({ jsonpFunction: false }); 208 | }); 209 | 210 | it('should pass and return error message', async () => { 211 | await app.httpRequest() 212 | .get('/error?_callback=fn') 213 | .expect(200) 214 | .expect('/**/ typeof fn === \'function\' && fn({"msg":"jsonpFunction is error"});'); 215 | }); 216 | }); 217 | --------------------------------------------------------------------------------