Repository: hapijs/hapi (language: javascript)

Issue:
Remove JSONP support
#### Support plan

* *is this issue currently blocking your project?* (yes/no): no
* *is this issue affecting a production system?* (yes/no): no

#### Context

* *node version*: any
* *module version*: 20.x
* *environment* (e.g. node, browser, native):  node
* *used with* (e.g. hapi application, another framework, standalone, ...):
* *any other relevant information*:

#### What problem are you trying to solve?

Simpler, more secure hapi.

#### Do you have a new or modified API suggestion to solve the problem?

Remove built-in JSONP support. See https://dev.to/benregenspan/the-state-of-jsonp-and-jsonp-vulnerabilities-in-2021-52ep for some recent insights.

While the JSONP feature does not effect any hapi usage where it is not enabled, it does add a bit of unnecessary maintenance burden and complicates the API. Given that no new projects should ever use this feature, and all use cases can be handled using the built-in CORS support, I say that this is a good candidate for removal in a future breaking release. Any project that still somehow requires it, should be able to handle manually or using a plugin.


Discussion / hints:

Fix patch:
diff --git a/API.md b/API.md
index 34e32c08c..5cb3ef3db 100755
--- a/API.md
+++ b/API.md
@@ -2966,21 +2966,6 @@ string payload or escaping it after stringification. Supports the following:
 - `escape` - calls [`Hoek.jsonEscape()`](https://hapi.dev/family/hoek/api/#escapejsonstring)
   after conversion to JSON string. Defaults to `false`.
 
-### <a name="route.options.jsonp" /> `route.options.jsonp`
-
-Default value: none.
-
-Enables JSONP support by setting the value to the query parameter name containing the function name
-used to wrap the response payload.
-
-For example, if the value is `'callback'`, a request comes in with `'callback=me'`, and the JSON
-response is `'{ "a":"b" }'`, the payload will be `'me({ "a":"b" });'`. Cannot be used with stream
-responses.
-
-The 'Content-Type' response header is set to `'text/javascript'` and the 'X-Content-Type-Options'
-response header is set to `'nosniff'`, and will override those headers even if explicitly set by
-[`response.type()`](#response.type()).
-
 ### <a name="route.options.log" /> `route.options.log`
 
 Default value: `{ collect: false }`.
@@ -3608,19 +3593,12 @@ the same. The following is the complete list of steps a request can go through:
     - [`request.route`](#request.route) is unassigned.
     - [`request.url`](#request.url) can be `null` if the incoming request path is invalid.
     - [`request.path`](#request.path) can be an invalid path.
-    - JSONP configuration is ignored for any response returned from the extension point since no
-      route is matched yet and the JSONP configuration is unavailable.
 
 - _**Route lookup**_
     - lookup based on `request.path` and `request.method`.
     - skips to _**onPreResponse**_ if no route is found or if the path violates the HTTP
       specification.
 
-- _**JSONP processing**_
-    - based on the route [`jsonp`](#route.options.jsonp) option.
-    - parses JSONP parameter from [`request.query`](#request.query).
-    - skips to _**Response validation**_ on error.
-
 - _**Cookies processing**_
     - based on the route [`state`](#route.options.state) option.
     - error handling based on [`failAction`](#route.options.state.failAction).
@@ -3655,10 +3633,6 @@ the same. The following is the complete list of steps a request can go through:
     - based on the route [`validate.params`](#route.options.validate.params) option.
     - error handling based on [`failAction`](#route.options.validate.failAction).
 
-- _**JSONP cleanup**_
-    - based on the route [`jsonp`](#route.options.jsonp) option.
-    - remove the JSONP parameter from [`request.query`](#request.query).
-
 - _**Query validation**_
     - based on the route [`validate.query`](#route.options.validate.query) option.
     - error handling based on [`failAction`](#route.options.validate.failAction).
diff --git a/lib/config.js b/lib/config.js
index 5279a187d..94ca0586f 100755
--- a/lib/config.js
+++ b/lib/config.js
@@ -134,7 +134,6 @@ internals.routeBase = Validate.object({
         escape: Validate.boolean().default(false)
     })
         .default(),
-    jsonp: Validate.string(),
     log: Validate.object({
         collect: Validate.boolean().default(false)
     })
diff --git a/lib/headers.js b/lib/headers.js
index 8e3a86654..bdb2a3833 100755
--- a/lib/headers.js
+++ b/lib/headers.js
@@ -40,17 +40,7 @@ exports.content = async function (response) {
 
         await response._marshal();
 
-        if (request.jsonp &&
-            response._payload.jsonp) {
-
-            response._header('content-type', 'text/javascript' + (response.settings.charset ? '; charset=' + response.settings.charset : ''));
-            response._header('x-content-type-options', 'nosniff');
-            response._payload.jsonp(request.jsonp);
-        }
-
-        if (response._payload.size &&
-            typeof response._payload.size === 'function') {
-
+        if (typeof response._payload.size === 'function') {
             response._header('content-length', response._payload.size(), { override: false });
         }
 
diff --git a/lib/request.js b/lib/request.js
index 1cfc0b89d..071dbb7c1 100755
--- a/lib/request.js
+++ b/lib/request.js
@@ -15,7 +15,7 @@ const Transmit = require('./transmit');
 
 const internals = {
     events: Podium.validate(['finish', { name: 'peek', spread: true }, 'disconnect']),
-    reserved: ['server', 'url', 'query', 'path', 'method', 'mime', 'setUrl', 'setMethod', 'headers', 'id', 'app', 'plugins', 'route', 'auth', 'pre', 'preResponses', 'info', 'isInjected', 'orig', 'params', 'paramsArray', 'payload', 'state', 'jsonp', 'response', 'raw', 'domain', 'log', 'logs', 'generateResponse']
+    reserved: ['server', 'url', 'query', 'path', 'method', 'mime', 'setUrl', 'setMethod', 'headers', 'id', 'app', 'plugins', 'route', 'auth', 'pre', 'preResponses', 'info', 'isInjected', 'orig', 'params', 'paramsArray', 'payload', 'state', 'response', 'raw', 'domain', 'log', 'logs', 'generateResponse']
 };
 
 
@@ -41,7 +41,6 @@ exports = module.exports = internals.Request = class {
 
         this.app = options.app ? Object.assign({}, options.app) : {};                                       // Place for application-specific state without conflicts with hapi, should not be used by plugins (shallow cloned)
         this.headers = req.headers;
-        this.jsonp = null;
         this.logs = [];
         this.method = req.method.toLowerCase();
         this.mime = null;
diff --git a/lib/response.js b/lib/response.js
index 6bb8044c1..8840c661b 100755
--- a/lib/response.js
+++ b/lib/response.js
@@ -711,68 +711,33 @@ internals.Response.Payload = class extends Stream.Readable {
         super();
 
         this._data = payload;
-        this._prefix = null;
-        this._suffix = null;
-        this._sizeOffset = 0;
         this._encoding = options.encoding;
     }
 
     _read(size) {
 
-        if (this._prefix) {
-            this.push(this._prefix, this._encoding);
-        }
-
         if (this._data) {
             this.push(this._data, this._encoding);
         }
 
-        if (this._suffix) {
-            this.push(this._suffix, this._encoding);
-        }
-
         this.push(null);
     }
 
     size() {
 
         if (!this._data) {
-            return this._sizeOffset;
+            return 0;
         }
 
-        return (Buffer.isBuffer(this._data) ? this._data.length : Buffer.byteLength(this._data, this._encoding)) + this._sizeOffset;
-    }
-
-    jsonp(variable) {
-
-        this._sizeOffset = this._sizeOffset + variable.length + 7;
-        this._prefix = '/**/' + variable + '(';                 // '/**/' prefix prevents CVE-2014-4671 security exploit
-
-        if (this._data !== null &&
-            !Buffer.isBuffer(this._data)) {
-
-            this._data = this._data
-                .replace(/\u2028/g, '\\u2028')
-                .replace(/\u2029/g, '\\u2029');
-        }
-
-        this._suffix = ');';
+        return Buffer.isBuffer(this._data) ? this._data.length : Buffer.byteLength(this._data, this._encoding);
     }
 
     writeToStream(stream) {
 
-        if (this._prefix) {
-            stream.write(this._prefix, this._encoding);
-        }
-
         if (this._data) {
             stream.write(this._data, this._encoding);
         }
 
-        if (this._suffix) {
-            stream.write(this._suffix, this._encoding);
-        }
-
         stream.end();
     }
 };
diff --git a/lib/route.js b/lib/route.js
index 0e6a70dcd..6feca6011 100755
--- a/lib/route.js
+++ b/lib/route.js
@@ -2,7 +2,6 @@
 
 const Assert = require('assert');
 
-const Boom = require('@hapi/boom');
 const Bounce = require('@hapi/bounce');
 const Catbox = require('@hapi/catbox');
 const Hoek = require('@hapi/hoek');
@@ -127,7 +126,6 @@ exports = module.exports = internals.Route = class {
 
         this._assert(!this.settings.validate.payload || this.settings.payload.parse, 'Route payload must be set to \'parse\' when payload validation enabled');
         this._assert(!this.settings.validate.state || this.settings.state.parse, 'Route state must be set to \'parse\' when state validation enabled');
-        this._assert(!this.settings.jsonp || typeof this.settings.jsonp === 'string', 'Bad route JSONP parameter name');
 
         // Authentication configuration
 
@@ -232,10 +230,6 @@ exports = module.exports = internals.Route = class {
 
         // 'onRequest'
 
-        if (this.settings.jsonp) {
-            this._cycle.push(internals.parseJSONP);
-        }
-
         if (this.settings.state.parse) {
             this._cycle.push(internals.state);
         }
@@ -278,10 +272,6 @@ exports = module.exports = internals.Route = class {
             this._cycle.push(Validation.params);
         }
 
-        if (this.settings.jsonp) {
-            this._cycle.push(internals.cleanupJSONP);
-        }
-
         if (this.settings.validate.query) {
             this._cycle.push(Validation.query);
         }
@@ -457,30 +447,6 @@ internals.drain = async function (request) {
 };
 
 
-internals.jsonpRegex = /^[\w\$\[\]\.]+$/;
-
-
-internals.parseJSONP = function (request) {
-
-    const jsonp = request.query[request.route.settings.jsonp];
-    if (jsonp) {
-        if (internals.jsonpRegex.test(jsonp) === false) {
-            throw Boom.badRequest('Invalid JSONP parameter value');
-        }
-
-        request.jsonp = jsonp;
-    }
-};
-
-
-internals.cleanupJSONP = function (request) {
-
-    if (request.jsonp) {
-        delete request.query[request.route.settings.jsonp];
-    }
-};
-
-
 internals.config = function (chain) {
 
     if (!chain.length) {


Test patch:
diff --git a/test/headers.js b/test/headers.js
index 70edba394..c6a447649 100755
--- a/test/headers.js
+++ b/test/headers.js
@@ -6,7 +6,6 @@ const Code = require('@hapi/code');
 const Hapi = require('..');
 const Inert = require('@hapi/inert');
 const Lab = require('@hapi/lab');
-const Wreck = require('@hapi/wreck');
 
 
 const internals = {};
@@ -513,17 +512,6 @@ describe('Headers', () => {
             expect(res.headers['content-type']).to.equal('text/html');
         });
 
-        it('returns a normal response when JSONP requested but stream returned', async () => {
-
-            const server = Hapi.server();
-            const stream = Wreck.toReadableStream('test');
-            stream.size = 4;                                    // Non function for coverage
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => stream } });
-
-            const res = await server.inject('/?callback=me');
-            expect(res.payload).to.equal('test');
-        });
-
         it('does not set content-type by default on 204 response', async () => {
 
             const server = Hapi.server();
diff --git a/test/response.js b/test/response.js
index f0757fba6..28a3fb303 100755
--- a/test/response.js
+++ b/test/response.js
@@ -1553,16 +1553,4 @@ describe('Response', () => {
             await finish;
         });
     });
-
-    describe('Payload', () => {
-
-        it('streams empty string', async () => {
-
-            const server = Hapi.server({ compression: { minBytes: 1 } });
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => '' } });
-
-            const res = await server.inject({ url: '/?callback=me', headers: { 'Accept-Encoding': 'gzip' } });
-            expect(res.statusCode).to.equal(200);
-        });
-    });
 });
diff --git a/test/transmit.js b/test/transmit.js
index d7af8065f..fd19e3784 100755
--- a/test/transmit.js
+++ b/test/transmit.js
@@ -360,138 +360,6 @@ describe('transmission', () => {
             expect(res.statusCode).to.equal(201);
         });
 
-        it('returns an JSONP response', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } });
-
-            const res = await server.inject('/?callback=me');
-            expect(res.payload).to.equal('/**/me({"some":"value"});');
-            expect(res.headers['content-length']).to.equal(25);
-            expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8');
-        });
-
-        it('returns an JSONP response with no payload', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => null } });
-
-            const res = await server.inject('/?callback=me');
-            expect(res.payload).to.equal('/**/me();');
-            expect(res.headers['content-length']).to.equal(9);
-            expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8');
-        });
-
-        it('returns an JSONP response (no charset)', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: (request, h) => h.response({ some: 'value' }).charset('') } });
-
-            const res = await server.inject('/?callback=me');
-            expect(res.payload).to.equal('/**/me({"some":"value"});');
-            expect(res.headers['content-length']).to.equal(25);
-            expect(res.headers['content-type']).to.equal('text/javascript');
-        });
-
-        it('returns a X-Content-Type-Options: nosniff header on JSONP responses', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } });
-
-            const res = await server.inject('/?callback=me');
-            expect(res.payload).to.equal('/**/me({"some":"value"});');
-            expect(res.headers['x-content-type-options']).to.equal('nosniff');
-        });
-
-        it('returns a normal response when JSONP enabled but not requested', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } });
-
-            const res = await server.inject('/');
-            expect(res.payload).to.equal('{"some":"value"}');
-        });
-
-        it('returns an JSONP response with compression', async () => {
-
-            const server = Hapi.server({ compression: { minBytes: 1 } });
-            server.route({
-                method: 'GET',
-                path: '/user/{name*2}',
-                options: {
-                    handler: (request) => {
-
-                        const parts = request.params.name.split('/');
-                        return { first: parts[0], last: parts[1] };
-                    },
-                    jsonp: 'callback'
-                }
-            });
-
-            const res = await server.inject({ url: '/user/1/2?callback=docall', headers: { 'accept-encoding': 'gzip' } });
-            expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8');
-            expect(res.headers['content-encoding']).to.equal('gzip');
-            expect(res.headers.vary).to.equal('accept-encoding');
-
-            const uncompressed = await internals.uncompress('unzip', res.rawPayload);
-            expect(uncompressed.toString()).to.equal('/**/docall({"first":"1","last":"2"});');
-        });
-
-        it('returns an JSONP response when response is a buffer', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => Buffer.from('value') } });
-
-            const res = await server.inject('/?callback=me');
-            expect(res.payload).to.equal('/**/me(value);');
-            expect(res.headers['content-length']).to.equal(14);
-        });
-
-        it('returns response on bad JSONP parameter', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } });
-
-            const res = await server.inject('/?callback=me*');
-            expect(res.result).to.exist();
-            expect(res.result.message).to.equal('Invalid JSONP parameter value');
-        });
-
-        it('returns an JSONP handler error', async () => {
-
-            const handler = () => {
-
-                throw Boom.badRequest('wrong');
-            };
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler } });
-
-            const res = await server.inject('/?callback=me');
-            expect(res.payload).to.equal('/**/me({"statusCode":400,"error":"Bad Request","message":"wrong"});');
-            expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8');
-        });
-
-        it('returns an JSONP state error', async () => {
-
-            const server = Hapi.server();
-            server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => 'ok' } });
-
-            let validState = false;
-            const preResponse = (request, h) => {
-
-                validState = request.state && typeof request.state === 'object';
-                return h.continue;
-            };
-
-            server.ext('onPreResponse', preResponse);
-
-            const res = await server.inject({ method: 'GET', url: '/?callback=me', headers: { cookie: '+' } });
-            expect(res.payload).to.equal('/**/me({"statusCode":400,"error":"Bad Request","message":"Invalid cookie header"});');
-            expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8');
-            expect(validState).to.equal(true);
-        });
-
         it('sets specific caching headers', async () => {
 
             const server = Hapi.server();