Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions Parse-Dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ module.exports = function(config, options) {
cookieSessionStore: options.cookieSessionStore
});

/**
* Checks whether a request is from localhost.
*/
function isLocalRequest(req) {
return req.connection.remoteAddress === '127.0.0.1' ||
req.connection.remoteAddress === '::ffff:127.0.0.1' ||
req.connection.remoteAddress === '::1';
}

/**
* Middleware that enforces remote access restrictions:
* - Requires HTTPS for remote requests (unless allowInsecureHTTP is set)
* - 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) {
return res.status(403).json({ error: 'Parse Dashboard can only be remotely accessed via HTTPS' });
}
if (!users) {
return res.status(401).json({ error: 'Configure a user to access Parse Dashboard remotely' });
}
}
next();
}

// CSRF error handler
app.use(function (err, req, res, next) {
if (err.code !== 'EBADCSRFTOKEN') {return next(err)}
Expand All @@ -109,13 +135,7 @@ module.exports = function(config, options) {
agent: config.agent,
};

//Based on advice from Doug Wilson here:
//https://github.com/expressjs/express/issues/2518
const requestIsLocal =
req.connection.remoteAddress === '127.0.0.1' ||
req.connection.remoteAddress === '::ffff:127.0.0.1' ||
req.connection.remoteAddress === '::1';
if (!options.dev && !requestIsLocal) {
if (!options.dev && !isLocalRequest(req)) {
if (!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' });
Expand Down Expand Up @@ -179,7 +199,7 @@ module.exports = function(config, options) {

//They didn't provide auth, and have configured the dashboard to not need auth
//(ie. didn't supply usernames and passwords)
if (requestIsLocal || options.dev) {
if (isLocalRequest(req) || options.dev) {
//Allow no-auth access on localhost only, if they have configured the dashboard to not need auth
await Promise.all(
response.apps.map(async (app) => {
Expand Down Expand Up @@ -329,8 +349,9 @@ module.exports = function(config, options) {
}
}

// Agent API endpoint — middleware chain: auth check (401) → CSRF validation (403) → handler
// Agent API endpoint — middleware chain: remote access guard → auth check (401) → CSRF validation (403) → handler
app.post('/apps/:appId/agent',
enforceRemoteAccessRestrictions,
(req, res, next) => {
if (users && (!req.user || !req.user.isAuthenticated)) {
return res.status(401).json({ error: 'Unauthorized' });
Expand Down
203 changes: 203 additions & 0 deletions src/lib/tests/AgentAuth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,206 @@ describe('Agent endpoint security', () => {
expect(res.status).not.toBe(403);
});
});

// ---------------------------------------------------------------
// No-user mode — remote access guard
// ---------------------------------------------------------------

describe('Agent endpoint no-user mode', () => {
let server;
let port;

const noUserConfig = {
apps: [
{
serverURL: 'http://localhost:1337/parse',
appId: 'testAppId',
masterKey: 'testMasterKey',
appName: 'TestApp',
},
],
// No users configured
agent: {
models: [
{
name: 'test-model',
provider: 'openai',
model: 'gpt-4',
apiKey: 'fake-api-key-for-testing',
},
],
},
};

beforeAll((done) => {
const parseDashboard = require('../../../Parse-Dashboard/app.js');
// dev: false to enable the remote access guard
const dashboardApp = parseDashboard(noUserConfig, {
cookieSessionSecret: SESSION_SECRET,
});

const parentApp = express();
parentApp.use('/', dashboardApp);

server = parentApp.listen(0, () => {
port = server.address().port;
done();
});
});

afterAll((done) => {
if (server) {
server.close(done);
} else {
done();
}
});

it('allows local requests to the agent endpoint in no-user mode', async () => {
// Requests to 127.0.0.1 are local, so they should pass the guard
const res = await makeRequest(port, {
method: 'POST',
path: '/apps/TestApp/agent',
body: agentBody(),
});
// Should not be blocked by the no-user guard (may fail at CSRF or later)
expect(res.status).not.toBe(401);
});
});

describe('Agent endpoint no-user mode — remote requests', () => {
let server;
let port;

const noUserConfig = {
apps: [
{
serverURL: 'http://localhost:1337/parse',
appId: 'testAppId',
masterKey: 'testMasterKey',
appName: 'TestApp',
},
],
agent: {
models: [
{
name: 'test-model',
provider: 'openai',
model: 'gpt-4',
apiKey: 'fake-api-key-for-testing',
},
],
},
};

beforeAll((done) => {
const parseDashboard = require('../../../Parse-Dashboard/app.js');
const dashboardApp = parseDashboard(noUserConfig, {
cookieSessionSecret: SESSION_SECRET,
});

const parentApp = express();
// Spoof a non-local remote address before the dashboard middleware
parentApp.use((req, _res, next) => {
Object.defineProperty(req.connection, 'remoteAddress', {
value: '203.0.113.1',
writable: true,
configurable: true,
});
next();
});
parentApp.use('/', dashboardApp);

server = parentApp.listen(0, () => {
port = server.address().port;
done();
});
});

afterAll((done) => {
if (server) {
server.close(done);
} else {
done();
}
});

it('returns 403 for remote non-HTTPS requests to the agent endpoint', async () => {
const res = await makeRequest(port, {
method: 'POST',
path: '/apps/TestApp/agent',
body: agentBody(),
});
expect(res.status).toBe(403);
expect(res.body.error).toBe('Parse Dashboard can only be remotely accessed via HTTPS');
});
});

describe('Agent endpoint no-user mode — remote requests with allowInsecureHTTP', () => {
let server;
let port;

const noUserConfig = {
apps: [
{
serverURL: 'http://localhost:1337/parse',
appId: 'testAppId',
masterKey: 'testMasterKey',
appName: 'TestApp',
},
],
agent: {
models: [
{
name: 'test-model',
provider: 'openai',
model: 'gpt-4',
apiKey: 'fake-api-key-for-testing',
},
],
},
};

beforeAll((done) => {
const parseDashboard = require('../../../Parse-Dashboard/app.js');
const dashboardApp = parseDashboard(noUserConfig, {
cookieSessionSecret: SESSION_SECRET,
allowInsecureHTTP: true,
});

const parentApp = express();
// Spoof a non-local remote address before the dashboard middleware
parentApp.use((req, _res, next) => {
Object.defineProperty(req.connection, 'remoteAddress', {
value: '203.0.113.1',
writable: true,
configurable: true,
});
next();
});
parentApp.use('/', dashboardApp);

server = parentApp.listen(0, () => {
port = server.address().port;
done();
});
});

afterAll((done) => {
if (server) {
server.close(done);
} else {
done();
}
});

it('returns 401 for remote requests to the agent endpoint in no-user mode when HTTPS is bypassed', async () => {
const res = await makeRequest(port, {
method: 'POST',
path: '/apps/TestApp/agent',
body: agentBody(),
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('Configure a user to access Parse Dashboard remotely');
});
});
Loading