From 3ff833db6444a3e931db9b76bf74c3420e57ee02 Mon Sep 17 00:00:00 2001 From: Alex Potsides Date: Mon, 14 Sep 2020 13:18:48 +0100 Subject: [PATCH] fix: disable cors by default (#3275) Co-authored-by: Marcin Rataj Brings js-ipfs into line with go-ipfs by not having CORS on by default, instead requiring the user to explicitly configure it. BREAKING CHANGE: - CORS origins will need to be [configured manually](https://github.com/ipfs/js-ipfs/blob/master/packages/ipfs-http-client/README.md#cors) before use with ipfs-http-client --- docs/CONFIG.md | 59 +++++++ packages/ipfs-http-client/README.md | 2 + packages/ipfs/src/http/api/routes/index.js | 26 ---- packages/ipfs/src/http/error-handler.js | 10 +- packages/ipfs/src/http/index.js | 73 ++++++--- packages/ipfs/test/http-api/cors.js | 153 +++++++++++++++++++ packages/ipfs/test/http-api/index.js | 1 + packages/ipfs/test/utils/http.js | 4 +- packages/ipfs/test/utils/test-http-method.js | 2 +- 9 files changed, 275 insertions(+), 55 deletions(-) create mode 100644 packages/ipfs/test/http-api/cors.js diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 130121088e..7ccb424e4d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -25,6 +25,13 @@ The js-ipfs config file is a JSON document located in the root directory of the - [`Enabled`](#enabled) - [`Swarm`](#swarm-1) - [`ConnMgr`](#connmgr) + - [Example](#example) +- [`API`](#api-1) + - [`HTTPHeaders`](#httpheaders) + - [`Access-Control-Allow-Origin`](#access-control-allow-origin) + - [Example](#example-1) + - [`Access-Control-Allow-Credentials`](#access-control-allow-credentials) + - [Example](#example-2) ## Profiles @@ -264,3 +271,55 @@ The "basic" connection manager tries to keep between `LowWater` and `HighWater` } } ``` + +## `API` + +Settings applied to the HTTP RPC API server + +### `HTTPHeaders` + +HTTP header settings used by the HTTP RPC API server + +#### `Access-Control-Allow-Origin` + +The RPC API endpoints running on your local node are protected by the [Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) mechanism. + +When a request is made that sends an `Origin` header, that Origin must be present in the allowed origins configured for the node, otherwise the browser will disallow that request to proceed, unless `mode: 'no-cors'` is set on the request, in which case the response will be opaque. + +To allow requests from web browsers, configure the `API.HTTPHeaders.Access-Control-Allow-Origin` setting. This is an array of URL strings with safelisted Origins. + +##### Example + +If you are running a webapp locally that you access via the URL `http://127.0.0.1:3000`, you must add it to the list of allowed origins in order to make API requests from that webapp in the browser: + +```json +{ + "API": { + "HTTPHeaders": { + "Access-Control-Allow-Origin": [ + "http://127.0.0.1:3000" + ] + } + } +} +``` + +Note that the origin must match exactly so `'http://127.0.0.1:3000'` is treated differently to `'http://127.0.0.1:3000/'` + +#### `Access-Control-Allow-Credentials` + +The [Access-Control-Allow-Credentials](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials) header allows client-side JavaScript running in the browser to send and receive credentials with requests - cookies, auth headers or TLS certificates. + +For most applications this will not be necessary but if you require this to be set, see the example below for how to configure it. + +##### Example + +```json +{ + "API": { + "HTTPHeaders": { + "Access-Control-Allow-Credentials": true + } + } +} +``` diff --git a/packages/ipfs-http-client/README.md b/packages/ipfs-http-client/README.md index 24d5bfb651..77d2a09344 100644 --- a/packages/ipfs-http-client/README.md +++ b/packages/ipfs-http-client/README.md @@ -342,6 +342,8 @@ $ ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["http://exam $ ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "POST", "GET"]' ``` +If you are using `js-ipfs`, substitute `ipfs` for `jsipfs` in the commands above. + ### Custom Headers If you wish to send custom headers with each request made by this library, for example, the Authorization header. You can use the config to do so: diff --git a/packages/ipfs/src/http/api/routes/index.js b/packages/ipfs/src/http/api/routes/index.js index 1e81cc77ef..2b514e1e5b 100644 --- a/packages/ipfs/src/http/api/routes/index.js +++ b/packages/ipfs/src/http/api/routes/index.js @@ -1,16 +1,5 @@ 'use strict' -const Boom = require('@hapi/boom') - -// RPC API requires POST, we block every other method -const BLOCKED_METHODS = [ - 'GET', - 'PUT', - 'PATCH', - 'DELETE', - 'OPTIONS' -] - const routes = [ require('./version'), require('./shutdown'), @@ -40,19 +29,4 @@ const routes = [ // webui is loaded from API port, but works over GET (not a part of RPC API) const extraRoutes = [...require('./webui')] -const handler = () => { - throw Boom.methodNotAllowed() -} - -// add routes that return HTTP 504 for non-POST requests to RPC API -BLOCKED_METHODS.forEach(method => { - routes.forEach(route => { - extraRoutes.push({ - method, - handler, - path: route.path - }) - }) -}) - module.exports = routes.concat(extraRoutes) diff --git a/packages/ipfs/src/http/error-handler.js b/packages/ipfs/src/http/error-handler.js index f63e2e36b3..f735dff5dd 100644 --- a/packages/ipfs/src/http/error-handler.js +++ b/packages/ipfs/src/http/error-handler.js @@ -39,10 +39,18 @@ module.exports = server => { server.logger.error(res) } - return h.response({ + const response = h.response({ Message: message, Code: code, Type: 'error' }).code(statusCode) + + const headers = res.output.headers || {} + + Object.keys(headers).forEach(header => { + response.header(header, headers[header]) + }) + + return response }) } diff --git a/packages/ipfs/src/http/index.js b/packages/ipfs/src/http/index.js index ce35b3d3ce..17ad751b6f 100644 --- a/packages/ipfs/src/http/index.js +++ b/packages/ipfs/src/http/index.js @@ -25,7 +25,7 @@ function hapiInfoToMultiaddr (info) { return toMultiaddr(uri) } -async function serverCreator (serverAddrs, createServer, ipfs) { +async function serverCreator (serverAddrs, createServer, ipfs, cors) { serverAddrs = serverAddrs || [] // just in case the address is just string serverAddrs = Array.isArray(serverAddrs) ? serverAddrs : [serverAddrs] @@ -33,7 +33,7 @@ async function serverCreator (serverAddrs, createServer, ipfs) { const servers = [] for (const address of serverAddrs) { const addrParts = address.split('/') - const server = await createServer(addrParts[2], addrParts[4], ipfs) + const server = await createServer(addrParts[2], addrParts[4], ipfs, cors) await server.start() server.info.ma = hapiInfoToMultiaddr(server.info) servers.push(server) @@ -56,9 +56,14 @@ class HttpApi { const config = await ipfs.config.getAll() config.Addresses = config.Addresses || {} + config.API = config.API || {} + config.API.HTTPHeaders = config.API.HTTPHeaders || {} const apiAddrs = config.Addresses.API - this._apiServers = await serverCreator(apiAddrs, this._createApiServer, ipfs) + this._apiServers = await serverCreator(apiAddrs, this._createApiServer, ipfs, { + origin: config.API.HTTPHeaders['Access-Control-Allow-Origin'] || [], + credentials: Boolean(config.API.HTTPHeaders['Access-Control-Allow-Credentials']) + }) const gatewayAddrs = config.Addresses.Gateway this._gatewayServers = await serverCreator(gatewayAddrs, this._createGatewayServer, ipfs) @@ -67,14 +72,22 @@ class HttpApi { return this } - async _createApiServer (host, port, ipfs) { + async _createApiServer (host, port, ipfs, cors) { + cors = { + ...cors, + additionalHeaders: ['X-Stream-Output', 'X-Chunked-Output', 'X-Content-Length'], + additionalExposedHeaders: ['X-Stream-Output', 'X-Chunked-Output', 'X-Content-Length'] + } + + if (!cors.origin || !cors.origin.length) { + cors = false + } + const server = Hapi.server({ host, port, - // CORS is enabled by default - // TODO: shouldn't, fix this routes: { - cors: true, + cors, response: { emptyStatusCode: 200 } @@ -95,6 +108,34 @@ class HttpApi { } }) + // block all non-post or non-options requests + server.ext({ + type: 'onRequest', + method: function (request, h) { + if (request.method === 'post' || request.method === 'options') { + return h.continue + } + + if (request.method === 'get' && (request.path.startsWith('/ipfs') || request.path.startsWith('/webui'))) { + // allow requests to the webui + return h.continue + } + + throw Boom.methodNotAllowed() + } + }) + + // https://tools.ietf.org/html/rfc7231#section-6.5.5 + server.ext('onPreResponse', (request, h) => { + const { response } = request + + if (response.isBoom && response.output && response.output.statusCode === 405) { + response.output.headers.Allow = 'OPTIONS, POST' + } + + return h.continue + }) + // https://github.com/ipfs/go-ipfs-cmds/pull/193/files server.ext({ type: 'onRequest', @@ -144,24 +185,6 @@ class HttpApi { } }) - const setHeader = (key, value) => { - server.ext('onPreResponse', (request, h) => { - const { response } = request - if (response.isBoom) { - response.output.headers[key] = value - } else { - response.header(key, value) - } - return h.continue - }) - } - - // Set default headers - setHeader('Access-Control-Allow-Headers', - 'X-Stream-Output, X-Chunked-Output, X-Content-Length') - setHeader('Access-Control-Expose-Headers', - 'X-Stream-Output, X-Chunked-Output, X-Content-Length') - server.route(require('./api/routes')) errorHandler(server) diff --git a/packages/ipfs/test/http-api/cors.js b/packages/ipfs/test/http-api/cors.js new file mode 100644 index 0000000000..0341f2b475 --- /dev/null +++ b/packages/ipfs/test/http-api/cors.js @@ -0,0 +1,153 @@ +/* eslint max-nested-callbacks: ["error", 8] */ +/* eslint-env mocha */ +'use strict' + +const { expect } = require('aegir/utils/chai') +const http = require('../utils/http') +const sinon = require('sinon') + +describe('cors', () => { + let ipfs + + beforeEach(() => { + ipfs = { + id: sinon.stub().returns({ + id: 'id', + publicKey: 'publicKey', + addresses: 'addresses', + agentVersion: 'agentVersion', + protocolVersion: 'protocolVersion' + }) + } + }) + + describe('should allow configuring CORS', () => { + it('returns allowed origins when origin is supplied in request', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'POST', + url: '/api/v0/id', + headers: { + origin + } + }, { ipfs, cors: { origin: [origin] } }) + + expect(res).to.have.nested.property('headers.access-control-allow-origin', origin) + }) + + it('does not allow credentials when omitted in config', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'POST', + url: '/api/v0/id', + headers: { + origin + } + }, { ipfs, cors: { origin: [origin] } }) + + expect(res).to.not.have.nested.property('headers.access-control-allow-credentials') + }) + + it('returns allowed credentials when origin is supplied in request', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'POST', + url: '/api/v0/id', + headers: { + origin + } + }, { ipfs, cors: { origin: [origin], credentials: true } }) + + expect(res).to.have.nested.property('headers.access-control-allow-credentials', 'true') + }) + + it('does not return allowed origins when origin is not supplied in request', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'POST', + url: '/api/v0/id' + }, { ipfs, cors: { origin: [origin] } }) + + expect(res).to.not.have.nested.property('headers.access-control-allow-origin') + }) + + it('does not return allowed credentials when origin is not supplied in request', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'POST', + url: '/api/v0/id' + }, { ipfs, cors: { origin: [origin], credentials: true } }) + + expect(res).to.not.have.nested.property('headers.access-control-allow-credentials') + }) + + it('does not return allowed origins when different origin is supplied in request', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'POST', + url: '/api/v0/id', + headers: { + origin: origin + '/' + } + }, { ipfs, cors: { origin: [origin] } }) + + expect(res).to.not.have.nested.property('headers.access-control-allow-origin') + }) + + it('allows wildcard origins', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'POST', + url: '/api/v0/id', + headers: { + origin: origin + '/' + } + }, { ipfs, cors: { origin: ['*'] } }) + + expect(res).to.have.nested.property('headers.access-control-allow-origin', origin + '/') + }) + + it('makes preflight request for post', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'OPTIONS', + url: '/api/v0/id', + headers: { + origin, + 'Access-Control-Request-Method': 'POST', + // browsers specifying custom headers triggers CORS pre-flight requests + // so simulate that here + 'Access-Control-Request-Headers': 'X-Stream-Output' + } + }, { + ipfs, + cors: { origin: [origin] } + }) + + expect(res).to.have.property('statusCode', 200) + expect(res).to.have.nested.property('headers.access-control-allow-origin', origin) + expect(res).to.have.nested.property('headers.access-control-allow-methods').that.includes('POST') + expect(res).to.have.nested.property('headers.access-control-allow-headers').that.includes('X-Stream-Output') + }) + + it('responds with 404 for preflight request for get', async () => { + const origin = 'http://localhost:8080' + const res = await http({ + method: 'OPTIONS', + url: '/api/v0/id', + headers: { + origin, + 'Access-Control-Request-Method': 'GET', + // browsers specifying custom headers triggers CORS pre-flight requests + // so simulate that here + 'Access-Control-Request-Headers': 'X-Stream-Output' + } + }, { + ipfs, + cors: { origin: [origin] } + }) + + expect(res).to.have.property('statusCode', 404) + }) + }) +}) diff --git a/packages/ipfs/test/http-api/index.js b/packages/ipfs/test/http-api/index.js index 8ee36db00d..977dede954 100644 --- a/packages/ipfs/test/http-api/index.js +++ b/packages/ipfs/test/http-api/index.js @@ -6,4 +6,5 @@ if (isNode) { require('./routes') } +require('./cors') require('./interface') diff --git a/packages/ipfs/test/utils/http.js b/packages/ipfs/test/utils/http.js index 4ee8c66363..854a13aceb 100644 --- a/packages/ipfs/test/utils/http.js +++ b/packages/ipfs/test/utils/http.js @@ -2,9 +2,9 @@ const HttpApi = require('../../src/http') -module.exports = async (request, { ipfs } = {}) => { +module.exports = async (request, { ipfs, cors } = {}) => { const api = new HttpApi(ipfs) - const server = await api._createApiServer('127.0.0.1', 8080, ipfs) + const server = await api._createApiServer('127.0.0.1', 8080, ipfs, cors) return server.inject(request) } diff --git a/packages/ipfs/test/utils/test-http-method.js b/packages/ipfs/test/utils/test-http-method.js index 7d7ae98f47..20ce75365f 100644 --- a/packages/ipfs/test/utils/test-http-method.js +++ b/packages/ipfs/test/utils/test-http-method.js @@ -8,7 +8,6 @@ const METHODS = [ 'PUT', 'PATCH', 'DELETE', - 'OPTIONS', 'HEAD' ] @@ -20,5 +19,6 @@ module.exports = async (url, ipfs) => { }, { ipfs }) expect(res).to.have.property('statusCode', 405) + expect(res).to.have.nested.property('headers.allow', 'OPTIONS, POST') } }