From 2127fd8a67023c38a1980c1a05458fcd507f80f3 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:19:08 +0200 Subject: [PATCH 1/4] fix: GHSA-cxmc-9pr8-fpqc --- Parse-Dashboard/app.js | 24 +++- src/lib/tests/RemoteAccess.test.js | 222 +++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 src/lib/tests/RemoteAccess.test.js diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index 8c2845d8ac..74708531af 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -87,13 +87,31 @@ 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. + const forwardingHeaders = ['x-forwarded-for', 'x-forwarded-host', 'x-real-ip', 'forwarded']; + /** * Checks whether a request is from localhost. + * + * The socket peer address alone cannot answer this: a reverse proxy running + * on the same host as the dashboard connects over loopback, so every request + * it relays would look local no matter where the client actually is. The + * peer address is therefore only trusted when nothing indicates a proxy. */ 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. + if (forwardingHeaders.some(header => req.headers[header] !== undefined)) { + return false; + } + const address = req.socket.remoteAddress; + return address === '127.0.0.1' || + address === '::ffff:127.0.0.1' || + address === '::1'; } /** diff --git a/src/lib/tests/RemoteAccess.test.js b/src/lib/tests/RemoteAccess.test.js new file mode 100644 index 0000000000..b99f9cd0f2 --- /dev/null +++ b/src/lib/tests/RemoteAccess.test.js @@ -0,0 +1,222 @@ +/* + * 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 = {}) { + return new Promise((resolve) => { + const parseDashboard = require('../../../Parse-Dashboard/app.js'); + const parentApp = express(); + 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 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('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'); + }); +}); From cae50af1a2c826ab7b955c34842e7d47cfea2c9e Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:38:28 +0200 Subject: [PATCH 2/4] fix: Reject all x-forwarded-* headers in locality check --- Parse-Dashboard/app.js | 10 ++++++++-- src/lib/tests/RemoteAccess.test.js | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index 74708531af..0b5e297bae 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -89,7 +89,10 @@ module.exports = function(config, options) { // Headers set by reverse proxies to describe the original client. Their // presence means the request was relayed and did not originate on this host. - const forwardingHeaders = ['x-forwarded-for', 'x-forwarded-host', 'x-real-ip', 'forwarded']; + // 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. @@ -105,7 +108,10 @@ module.exports = function(config, options) { return false; } // Forwarding headers are never sent by a client on this host. - if (forwardingHeaders.some(header => req.headers[header] !== undefined)) { + const isForwarded = Object.keys(req.headers).some(header => + header.startsWith(forwardingHeaderPrefix) || forwardingHeaders.includes(header) + ); + if (isForwarded) { return false; } const address = req.socket.remoteAddress; diff --git a/src/lib/tests/RemoteAccess.test.js b/src/lib/tests/RemoteAccess.test.js index b99f9cd0f2..4b14fa25d2 100644 --- a/src/lib/tests/RemoteAccess.test.js +++ b/src/lib/tests/RemoteAccess.test.js @@ -141,6 +141,26 @@ describe('Config endpoint locality — requests forwarded by a same-host proxy', 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', From 891fb0e2b1ee65ca74bb0c0807ffcd34cf8e6369 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:06:59 +0200 Subject: [PATCH 3/4] fix: Keep the HTTPS requirement keyed on the connection --- Parse-Dashboard/app.js | 45 ++++++++++++++++++++---------- src/lib/tests/RemoteAccess.test.js | 36 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index 0b5e297bae..d6e49b6499 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -95,12 +95,26 @@ module.exports = function(config, options) { const forwardingHeaders = ['x-real-ip', 'forwarded']; /** - * Checks whether a request is from localhost. + * Checks whether the connection was opened from this host. * - * The socket peer address alone cannot answer this: a reverse proxy running - * on the same host as the dashboard connects over loopback, so every request - * it relays would look local no matter where the client actually is. The - * peer address is therefore only trusted when nothing indicates a proxy. + * 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) { + const address = req.socket.remoteAddress; + return address === '127.0.0.1' || + address === '::ffff:127.0.0.1' || + 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) { // A configured proxy relays every request, so no request is local. @@ -114,10 +128,7 @@ module.exports = function(config, options) { if (isForwarded) { return false; } - const address = req.socket.remoteAddress; - return address === '127.0.0.1' || - address === '::ffff:127.0.0.1' || - address === '::1'; + return isLoopbackPeer(req); } /** @@ -126,11 +137,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' }); } } @@ -159,13 +174,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 index 4b14fa25d2..d71ab296bf 100644 --- a/src/lib/tests/RemoteAccess.test.js +++ b/src/lib/tests/RemoteAccess.test.js @@ -218,6 +218,42 @@ describe('Config endpoint locality — genuine localhost requests', () => { }); }); +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('Agent endpoint locality — requests forwarded by a same-host proxy', () => { let server; let port; From 2e51f5626994cc3f36216f98ec187f38a0084cbd Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:12:39 +0200 Subject: [PATCH 4/4] fix: Recognize the full 127.0.0.0/8 loopback range --- Parse-Dashboard/app.js | 10 +++++--- src/lib/tests/RemoteAccess.test.js | 40 +++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index d6e49b6499..31ea47637c 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -103,10 +103,14 @@ module.exports = function(config, options) { * 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 address === '127.0.0.1' || - address === '::ffff:127.0.0.1' || - address === '::1'; + return typeof address === 'string' && ( + address.startsWith('127.') || + address.startsWith('::ffff:127.') || + address === '::1' + ); } /** diff --git a/src/lib/tests/RemoteAccess.test.js b/src/lib/tests/RemoteAccess.test.js index d71ab296bf..277234ba3a 100644 --- a/src/lib/tests/RemoteAccess.test.js +++ b/src/lib/tests/RemoteAccess.test.js @@ -84,10 +84,20 @@ function noUserConfig(overrides = {}) { * Start the dashboard mounted in a parent app, as the CLI and the express * middleware integration both do. */ -function startDashboard(config, options = {}) { +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 }) @@ -254,6 +264,34 @@ describe('Config endpoint locality — configured deployments behind a proxy', ( }); }); +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;