Skip to content
Open
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
33 changes: 33 additions & 0 deletions apps/daemon/src/import-export-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,29 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
pruneExpiredImportNonces,
verifyDesktopImportToken,
} = ctx.auth;

/**
* When the desktop auth gate is inactive (non-desktop mode), folder import
* and working-dir rebinding are privileged operations that should not be
* reachable by non-browser local processes. The server.ts origin middleware
* already validates browser origins, so a non-empty Origin header proves the
* request came from the legitimate web UI rather than a raw HTTP client
* (curl, a malicious script, etc.).
*
* Desktop mode requests are exempt — they go through the HMAC import token
* check below.
*
* Returns an error reason string, or null if the request is authorized.
* (issue #5480)
*/
function folderImportAuthorizationReason(req: import('express').Request): string | null {
if (isDesktopAuthGateActive()) return null;
const origin = req.headers.origin;
if (typeof origin !== 'string' || origin.length === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

folderImportAuthorizationReason() is treating the presence of an Origin header as proof that the request came from the web UI, but the daemon's own origin model does not give you that guarantee. server.ts explicitly allows non-browser clients when Origin is missing, and isAllowedBrowserOrigin() only checks that a supplied origin string looks like a same-host loopback/LAN origin. A raw local client can therefore send Origin: http://127.0.0.1:<port> (or another allowed loopback/LAN origin) and still reach these routes, so the unauthenticated directory-binding hole remains open even though the new no-Origin case is blocked. Please switch this gate to a credential that a local script cannot forge, such as keeping the existing desktop import-token/HMAC flow for these privileged routes or another explicit daemon-issued secret, and extend the regression coverage to prove that a non-browser request with a forged allowed Origin is rejected.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

return 'folder import requires a browser origin or desktop auth gate';
}
return null;
}
const { getProject, insertProject, updateProject } = ctx.projectStore;
const { insertConversation } = ctx.conversations;
const { setTabs } = ctx.projectFiles;
Expand Down Expand Up @@ -142,6 +165,11 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (!existing) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
// Non-desktop requests without a browser Origin are rejected (issue #5480).
const authReason = folderImportAuthorizationReason(req);
if (authReason) {
return sendApiError(res, 403, 'FORBIDDEN', authReason);
}
const { baseDir, orchestratorWorkspace } = req.body || {};
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
Expand Down Expand Up @@ -268,6 +296,11 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps

app.post('/api/import/folder', async (req, res) => {
try {
// Non-desktop requests without a browser Origin are rejected (issue #5480).
const authReason = folderImportAuthorizationReason(req);
if (authReason) {
return sendApiError(res, 403, 'FORBIDDEN', authReason);
}
const { baseDir, name, skillId, designSystemId, orchestratorWorkspace } = req.body || {};
if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
Expand Down
106 changes: 106 additions & 0 deletions apps/daemon/tests/folder-import-auth-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { registerImportRoutes } from '../src/import-export-routes.js';
import { resetDesktopAuthForTests } from '../src/desktop-auth.js';

/**
* Tests for the folder-import / working-dir auth gate (issue #5480).
*
* When isDesktopAuthGateActive() returns false (non-desktop mode), both
* POST /api/import/folder and POST /api/projects/:id/working-dir must
* reject requests that lack a browser Origin header, preventing arbitrary
* directory binding by non-browser local processes.
*/
describe('folder import auth gate (issue #5480)', () => {
let app: express.Express;

function createApp() {
const app = express();
app.use(express.json());

// Minimal mock context that satisfies the route registrar.
const ctx: any = {
db: {},
http: {
sendApiError: (res: any, status: number, _code: string, message: string) => {
res.status(status).json({ error: message });
},
},
uploads: { importUpload: () => [] },
node: { fs, path: require('node:path') },
ids: { randomId: () => 'test-id' },
paths: { PROJECTS_DIR: '/tmp/projects', RUNTIME_DATA_DIR_CANONICAL: '/tmp/data' },
imports: {
importClaudeDesignZip: vi.fn(),
projectDir: vi.fn(() => '/tmp/projects/test'),
detectEntryFile: vi.fn(async () => null),
},
auth: {
consumedImportNonces: new Map(),
desktopAuthSecret: () => null,
isDesktopAuthGateActive: () => false, // non-desktop mode
pruneExpiredImportNonces: vi.fn(),
verifyDesktopImportToken: vi.fn(),
},
projectStore: {
getProject: vi.fn(() => ({ id: 'test-id', metadata: {} })),
insertProject: vi.fn(),
updateProject: vi.fn(() => ({ id: 'test-id', metadata: {} })),
},
conversations: { insertConversation: vi.fn() },
projectFiles: { setTabs: vi.fn() },
validation: { validateProjectDesignSystemId: vi.fn(() => null) },
};

registerImportRoutes(app, ctx);
return app;
}

beforeEach(() => {
resetDesktopAuthForTests();
app = createApp();
});

afterEach(() => {
resetDesktopAuthForTests();
});

describe('POST /api/import/folder', () => {
it('rejects requests without Origin header (non-browser client)', async () => {
const res = await request(app)
.post('/api/import/folder')
.send({ baseDir: '/tmp' });
expect(res.status).toBe(403);
expect(res.body.error).toContain('browser origin');
});

it('accepts requests with a browser Origin header', async () => {
const res = await request(app)
.post('/api/import/folder')
.set('Origin', 'http://localhost:3000')
.send({ baseDir: '/tmp' });
// Should NOT get 403 — it may get 400 for other validation reasons,
// but must not be blocked by the auth gate.
expect(res.status).not.toBe(403);
});
});

describe('POST /api/projects/:id/working-dir', () => {
it('rejects requests without Origin header (non-browser client)', async () => {
const res = await request(app)
.post('/api/projects/test-id/working-dir')
.send({ baseDir: '/tmp' });
expect(res.status).toBe(403);
expect(res.body.error).toContain('browser origin');
});

it('accepts requests with a browser Origin header', async () => {
const res = await request(app)
.post('/api/projects/test-id/working-dir')
.set('Origin', 'http://localhost:3000')
.send({ baseDir: '/tmp' });
expect(res.status).not.toBe(403);
});
});
});
Loading