diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index 8c2845d8a..31ea47637 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -87,13 +87,52 @@ module.exports = function(config, options) { cookieSessionStore: options.cookieSessionStore }); + // Headers set by reverse proxies to describe the original client. Their + // presence means the request was relayed and did not originate on this host. + // The `x-forwarded-` prefix is matched as a whole because proxies vary in + // which of them they set, and any single one is enough to reveal a relay. + const forwardingHeaderPrefix = 'x-forwarded-'; + const forwardingHeaders = ['x-real-ip', 'forwarded']; + /** - * Checks whether a request is from localhost. + * Checks whether the connection was opened from this host. + * + * This describes the connection, not the client: a reverse proxy running on + * the same host also connects over loopback, so a true result does not mean + * the client is local. Use it only to decide whether the connection itself + * needs encrypting, never to grant access. + */ + function isLoopbackPeer(req) { + // The whole of 127.0.0.0/8 is loopback, not just 127.0.0.1. The address is + // absent on a socket that has already been destroyed. + const address = req.socket.remoteAddress; + return typeof address === 'string' && ( + address.startsWith('127.') || + address.startsWith('::ffff:127.') || + address === '::1' + ); + } + + /** + * Checks whether a request originated on this host. + * + * Stricter than `isLoopbackPeer`, because a same-host reverse proxy would + * otherwise make every request it relays look local. A request counts as + * local only when nothing indicates it was relayed. */ function isLocalRequest(req) { - return req.connection.remoteAddress === '127.0.0.1' || - req.connection.remoteAddress === '::ffff:127.0.0.1' || - req.connection.remoteAddress === '::1'; + // A configured proxy relays every request, so no request is local. + if (config.trustProxy) { + return false; + } + // Forwarding headers are never sent by a client on this host. + const isForwarded = Object.keys(req.headers).some(header => + header.startsWith(forwardingHeaderPrefix) || forwardingHeaders.includes(header) + ); + if (isForwarded) { + return false; + } + return isLoopbackPeer(req); } /** @@ -102,11 +141,15 @@ module.exports = function(config, options) { * - Requires users to be configured for remote access (unless dev mode is enabled) */ function enforceRemoteAccessRestrictions(req, res, next) { - if (!options.dev && !isLocalRequest(req)) { - if (!req.secure && !options.allowInsecureHTTP) { + if (!options.dev) { + // Keyed on the connection: a loopback connection cannot be observed off + // this host, so it needs no encryption of its own. + if (!isLoopbackPeer(req) && !req.secure && !options.allowInsecureHTTP) { return res.status(403).json({ error: 'Parse Dashboard can only be remotely accessed via HTTPS' }); } - if (!users) { + // Keyed on the client: granting access requires knowing where the + // request came from, which a relayed request cannot establish. + if (!isLocalRequest(req) && !users) { return res.status(401).json({ error: 'Configure a user to access Parse Dashboard remotely' }); } } @@ -135,13 +178,13 @@ module.exports = function(config, options) { agent: config.agent, }; - if (!options.dev && !isLocalRequest(req)) { - if (!req.secure && !options.allowInsecureHTTP) { + if (!options.dev) { + if (!isLoopbackPeer(req) && !req.secure && !options.allowInsecureHTTP) { //Disallow HTTP requests except on localhost, to prevent the master key from being transmitted in cleartext return res.send({ success: false, error: 'Parse Dashboard can only be remotely accessed via HTTPS' }); } - if (!users) { + if (!isLocalRequest(req) && !users) { //Accessing the dashboard over the internet can only be done with username and password return res.send({ success: false, error: 'Configure a user to access Parse Dashboard remotely' }); } diff --git a/src/lib/tests/RemoteAccess.test.js b/src/lib/tests/RemoteAccess.test.js new file mode 100644 index 000000000..277234ba3 --- /dev/null +++ b/src/lib/tests/RemoteAccess.test.js @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +jest.dontMock('../../../Parse-Dashboard/Authentication.js'); +jest.dontMock('../../../Parse-Dashboard/app.js'); + +const express = require('express'); +const http = require('http'); + +const MASTER_KEY = 'testMasterKey'; +const SESSION_SECRET = 'test-secret'; + +/** + * Helper to make HTTP requests to the test server. Requests always originate + * from loopback, which is what a reverse proxy running on the same host as the + * dashboard looks like from the dashboard's point of view. + */ +function makeRequest(port, { method = 'GET', path = '/', body = null, headers = {} }) { + return new Promise((resolve, reject) => { + const options = { hostname: '127.0.0.1', port, path, method, headers: { ...headers } }; + + if (body) { + const bodyStr = JSON.stringify(body); + options.headers['Content-Type'] = 'application/json'; + options.headers['Content-Length'] = Buffer.byteLength(bodyStr); + } + + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + let json = null; + try { + json = JSON.parse(data); + } catch { + // not JSON + } + resolve({ status: res.statusCode, body: json, raw: data }); + }); + }); + + req.on('error', reject); + + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +/** + * Build a dashboard config without users, which is the configuration that + * limits no-auth access to localhost. + */ +function noUserConfig(overrides = {}) { + return { + apps: [ + { + serverURL: 'http://localhost:1337/parse', + appId: 'testAppId', + masterKey: MASTER_KEY, + appName: 'TestApp', + }, + ], + agent: { + models: [ + { + name: 'test-model', + provider: 'openai', + model: 'gpt-4', + apiKey: 'fake-api-key-for-testing', + }, + ], + }, + ...overrides, + }; +} + +/** + * Start the dashboard mounted in a parent app, as the CLI and the express + * middleware integration both do. + */ +function startDashboard(config, options = {}, peerAddress) { + return new Promise((resolve) => { + const parseDashboard = require('../../../Parse-Dashboard/app.js'); + const parentApp = express(); + if (peerAddress !== undefined) { + parentApp.use((req, _res, next) => { + Object.defineProperty(req.connection, 'remoteAddress', { + value: peerAddress.value, + writable: true, + configurable: true, + }); + next(); + }); + } + parentApp.use( + '/', + parseDashboard(config, { cookieSessionSecret: SESSION_SECRET, ...options }) + ); + + const server = parentApp.listen(0, () => resolve({ server, port: server.address().port })); + }); +} + +function stopDashboard(server) { + return new Promise((resolve) => (server ? server.close(resolve) : resolve())); +} + +describe('Config endpoint locality — requests forwarded by a same-host proxy', () => { + let server; + let port; + + beforeAll(async () => { + ({ server, port } = await startDashboard(noUserConfig(), { allowInsecureHTTP: true })); + }); + + afterAll(() => stopDashboard(server)); + + it('does not return the master key for a request carrying X-Forwarded-For', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { 'X-Forwarded-For': '203.0.113.7' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); + + it('does not return the master key for a request carrying X-Real-IP', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { 'X-Real-IP': '203.0.113.7' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); + + it('does not return the master key for a request carrying X-Forwarded-Host', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { 'X-Forwarded-Host': 'dashboard.example.com' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); + + it('does not return the master key for a request carrying only X-Forwarded-Proto', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { 'X-Forwarded-Proto': 'https' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); + + it('does not return the master key for a request carrying only X-Forwarded-Port', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { 'X-Forwarded-Port': '443' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); + + it('does not return the master key for a request carrying a Forwarded header', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { Forwarded: 'for=203.0.113.7;proto=https;host=dashboard.example.com' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); +}); + +describe('Config endpoint locality — requests forwarded by a same-host proxy over HTTPS', () => { + let server; + let port; + + beforeAll(async () => { + ({ server, port } = await startDashboard(noUserConfig({ trustProxy: 1 }))); + }); + + afterAll(() => stopDashboard(server)); + + it('does not return the master key when the proxy reports a secure connection', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { 'X-Forwarded-For': '203.0.113.7', 'X-Forwarded-Proto': 'https' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); + + it('does not return the master key when a proxy is trusted but sends no forwarding headers', async () => { + const res = await makeRequest(port, { path: '/parse-dashboard-config.json' }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.body.error).toBeDefined(); + }); +}); + +describe('Config endpoint locality — genuine localhost requests', () => { + let server; + let port; + + beforeAll(async () => { + ({ server, port } = await startDashboard(noUserConfig())); + }); + + afterAll(() => stopDashboard(server)); + + it('returns the master key for a loopback request without forwarding headers', async () => { + const res = await makeRequest(port, { path: '/parse-dashboard-config.json' }); + + expect(res.status).toBe(200); + expect(res.body.apps[0].masterKey).toBe(MASTER_KEY); + }); +}); + +describe('Config endpoint locality — configured deployments behind a proxy', () => { + let server; + let port; + + const withUsers = { + apps: [ + { + serverURL: 'http://localhost:1337/parse', + appId: 'testAppId', + masterKey: MASTER_KEY, + appName: 'TestApp', + }, + ], + users: [{ user: 'admin', pass: 'admin' }], + }; + + beforeAll(async () => { + ({ server, port } = await startDashboard(withUsers)); + }); + + afterAll(() => stopDashboard(server)); + + // The HTTPS requirement is keyed on the connection, not on the client, so a + // proxy terminating TLS on this host keeps working without `trustProxy`. + it('does not impose the HTTPS requirement on a proxy terminating TLS on this host', async () => { + const res = await makeRequest(port, { + path: '/parse-dashboard-config.json', + headers: { 'X-Forwarded-For': '203.0.113.7', 'X-Forwarded-Proto': 'https' }, + }); + + expect(res.raw).not.toContain(MASTER_KEY); + expect(res.raw).not.toContain('can only be remotely accessed via HTTPS'); + expect(res.status).toBe(401); + }); +}); + +describe('Config endpoint locality — loopback addresses beyond 127.0.0.1', () => { + const servers = []; + + afterAll(() => Promise.all(servers.map(({ server }) => stopDashboard(server)))); + + async function get(peerValue) { + const started = await startDashboard(noUserConfig(), {}, { value: peerValue }); + servers.push(started); + return makeRequest(started.port, { path: '/parse-dashboard-config.json' }); + } + + it('treats any 127.0.0.0/8 address as originating on this host', async () => { + const res = await get('127.0.0.2'); + expect(res.body.apps[0].masterKey).toBe(MASTER_KEY); + }); + + it('treats an IPv4-mapped 127.0.0.0/8 address as originating on this host', async () => { + const res = await get('::ffff:127.0.0.2'); + expect(res.body.apps[0].masterKey).toBe(MASTER_KEY); + }); + + it('does not fail when the peer address is unavailable', async () => { + const res = await get(undefined); + expect(res.status).toBe(200); + expect(res.raw).not.toContain(MASTER_KEY); + }); +}); + +describe('Agent endpoint locality — requests forwarded by a same-host proxy', () => { + let server; + let port; + + beforeAll(async () => { + ({ server, port } = await startDashboard(noUserConfig(), { allowInsecureHTTP: true })); + }); + + afterAll(() => stopDashboard(server)); + + it('rejects a forwarded request in no-user mode', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: { message: 'List all classes', modelName: 'test-model' }, + headers: { 'X-Forwarded-For': '203.0.113.7' }, + }); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely'); + }); +});