diff --git a/cli.js b/cli.js index 14f17ad1..ab58bf2b 100644 --- a/cli.js +++ b/cli.js @@ -10412,8 +10412,20 @@ function extractRequestToken(req) { const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {}; const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : ''; if (rawAuth) { - const match = rawAuth.match(/^bearer\s+(.+)$/i); - if (match && match[1]) return match[1].trim(); + const bearerMatch = rawAuth.match(/^bearer\s+(.+)$/i); + if (bearerMatch && bearerMatch[1]) return bearerMatch[1].trim(); + const basicMatch = rawAuth.match(/^basic\s+(.+)$/i); + if (basicMatch && basicMatch[1]) { + try { + const decoded = Buffer.from(basicMatch[1].trim(), 'base64').toString('utf-8'); + const separatorIndex = decoded.indexOf(':'); + if (separatorIndex >= 0) { + const password = decoded.slice(separatorIndex + 1).trim(); + if (password) return password; + } + if (decoded.trim()) return decoded.trim(); + } catch (_) { } + } return rawAuth; } const raw = typeof headers['x-codexmate-token'] === 'string' ? headers['x-codexmate-token'].trim() : ''; @@ -10439,12 +10451,21 @@ function assertRequestAuthorized(req, res) { } const actual = extractRequestToken(req); if (!actual || !safeTimingEqual(actual, expected)) { - writeJsonResponse(res, 401, { error: 'Unauthorized' }); + writeJsonResponse(res, 401, { error: 'Unauthorized' }, { + 'WWW-Authenticate': 'Basic realm="codexmate"' + }); return { ok: false, mode: 'unauthorized' }; } return { ok: true, mode: 'token' }; } +function isProtectedWebSurfacePath(requestPath) { + return requestPath === '/' + || requestPath === '/web-ui/index.html' + || requestPath.startsWith('/web-ui/') + || requestPath.startsWith('/res/'); +} + const g_webhookDeliveryCache = new Map(); function pruneWebhookDeliveryCache() { @@ -10942,6 +10963,21 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser if (typeof openaiBridgeHandler === 'function' && openaiBridgeHandler(req, res)) { return; } + if (isProtectedWebSurfacePath(requestPath)) { + const remoteAddr = req && req.socket ? req.socket.remoteAddress : ''; + const isLoopback = !remoteAddr || isLoopbackRemoteAddress(remoteAddr); + if (!isLoopback) { + const rateLimitKey = (remoteAddr || 'unknown') + ':' + requestPath; + if (!checkRateLimit(rateLimitKey)) { + writeJsonResponse(res, 429, { error: 'Rate limit exceeded' }, { 'Retry-After': '60' }); + return; + } + const auth = assertRequestAuthorized(req, res); + if (!auth.ok) { + return; + } + } + } if ( requestPath === '/api' || requestPath.startsWith('/api/import-') diff --git a/package-lock.json b/package-lock.json index 49827900..4d862295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexmate", - "version": "0.0.41", + "version": "0.0.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.0.41", + "version": "0.0.42", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index c09e784f..a393e822 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexmate", - "version": "0.0.41", + "version": "0.0.42", "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具", "main": "cli.js", "bin": { diff --git a/tests/e2e/test-sessions.js b/tests/e2e/test-sessions.js index 7eb208cb..76db60dd 100644 --- a/tests/e2e/test-sessions.js +++ b/tests/e2e/test-sessions.js @@ -18,16 +18,27 @@ module.exports = async function testSessions(ctx) { assert(Array.isArray(apiSessions.sessions), 'api sessions missing'); assert(apiSessions.sessions.some(item => item.sessionId === sessionId), 'api sessions missing codex entry'); assert(typeof apiSessions.source === 'string', 'list-sessions missing source'); + const apiCodexEntry = apiSessions.sessions.find(item => item.sessionId === sessionId); + assert(apiCodexEntry && path.isAbsolute(apiCodexEntry.filePath || ''), 'codex session filePath should be absolute for copy-path'); + assert(apiCodexEntry && fs.existsSync(apiCodexEntry.filePath), 'codex session filePath should point to an existing file'); + assert(fs.statSync(apiCodexEntry.filePath).isFile(), 'codex session filePath should point to a file'); + assert(fs.readFileSync(apiCodexEntry.filePath, 'utf8').includes(sessionId), 'codex copied filePath should be readable session content'); // ========== List Sessions Tests - Claude ========== const apiSessionsClaude = await api('list-sessions', { source: 'claude', limit: 50, forceRefresh: true }); assert(Array.isArray(apiSessionsClaude.sessions), 'api sessions(claude) missing'); assert(apiSessionsClaude.sessions.some(item => item.sessionId === claudeSessionId), 'api sessions(claude) missing claude entry'); + const apiClaudeEntry = apiSessionsClaude.sessions.find(item => item.sessionId === claudeSessionId); + assert(apiClaudeEntry && path.isAbsolute(apiClaudeEntry.filePath || ''), 'claude session filePath should be absolute for copy-path'); + assert(apiClaudeEntry && fs.existsSync(apiClaudeEntry.filePath), 'claude session filePath should point to an existing file'); // ========== List Sessions Tests - Gemini ========== const apiSessionsGemini = await api('list-sessions', { source: 'gemini', limit: 50, forceRefresh: true }); assert(Array.isArray(apiSessionsGemini.sessions), 'api sessions(gemini) missing'); assert(apiSessionsGemini.sessions.some(item => item.sessionId === geminiSessionId), 'api sessions(gemini) missing gemini entry'); + const apiGeminiEntry = apiSessionsGemini.sessions.find(item => item.sessionId === geminiSessionId); + assert(apiGeminiEntry && path.isAbsolute(apiGeminiEntry.filePath || ''), 'gemini session filePath should be absolute for copy-path'); + assert(apiGeminiEntry && fs.existsSync(apiGeminiEntry.filePath), 'gemini session filePath should point to an existing file'); // ========== List Sessions Tests - All Sources ========== const apiSessionsAll = await api('list-sessions', { source: 'all', limit: 50, forceRefresh: true }); diff --git a/tests/unit/session-actions-standalone.test.mjs b/tests/unit/session-actions-standalone.test.mjs index b100da2b..7fc8d1de 100644 --- a/tests/unit/session-actions-standalone.test.mjs +++ b/tests/unit/session-actions-standalone.test.mjs @@ -59,3 +59,58 @@ test('copySessionLink shows an error when url cannot be built', async () => { type: 'error' }]); }); + +test('copySessionPath copies the session file path', async () => { + const methods = createSessionActionMethods({ apiBase: '' }); + const copied = []; + const context = { + ...methods, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + fallbackCopyText(value) { + copied.push(value); + return true; + } + }; + + await methods.copySessionPath.call(context, { + source: 'codex', + sessionId: 'session-1', + filePath: ' /tmp/codexmate/session-1.jsonl ' + }); + + assert.deepStrictEqual(copied, ['/tmp/codexmate/session-1.jsonl']); + assert.deepStrictEqual(context.shownMessages, [{ + message: '已复制路径', + type: 'success' + }]); +}); + +test('copySessionPath reports an error when the session has no file path', async () => { + const methods = createSessionActionMethods({ apiBase: '' }); + let copied = false; + const context = { + ...methods, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + fallbackCopyText() { + copied = true; + return true; + } + }; + + await methods.copySessionPath.call(context, { + source: 'codex', + sessionId: 'session-1' + }); + + assert.strictEqual(copied, false); + assert.deepStrictEqual(context.shownMessages, [{ + message: '无本地文件路径', + type: 'error' + }]); +}); diff --git a/tests/unit/web-run-host.test.mjs b/tests/unit/web-run-host.test.mjs index 697bd7aa..5d831a6c 100644 --- a/tests/unit/web-run-host.test.mjs +++ b/tests/unit/web-run-host.test.mjs @@ -720,6 +720,17 @@ const handleImportSkillsZipUploadSource = extractFunctionBySignature( 'async function handleImportSkillsZipUpload(req, res, options = {}) {', 'handleImportSkillsZipUpload' ); +const extractRequestTokenSource = extractFunctionBySignature( + cliContent, + 'function extractRequestToken(req) {', + 'extractRequestToken' +); +const extractRequestToken = instantiateFunction(extractRequestTokenSource, 'extractRequestToken', { Buffer }); +const assertRequestAuthorizedSource = extractFunctionBySignature( + cliContent, + 'function assertRequestAuthorized(req, res) {', + 'assertRequestAuthorized' +); const createWebServerSource = extractFunctionBySignature( cliContent, 'function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {', @@ -750,7 +761,8 @@ function createMockResponse() { function createWebServerHarness({ htmlReader = () => '', - dynamicAssets = new Map() + dynamicAssets = new Map(), + authorizeRequest = () => ({ ok: true }) } = {}) { let requestHandler = null; const errors = []; @@ -803,6 +815,10 @@ function createWebServerHarness({ isAnyAddressHost() { return false; }, + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, + assertRequestAuthorized: authorizeRequest, + writeJsonResponse: mockWriteJsonResponse, + isProtectedWebSurfacePath: mockIsProtectedWebSurfacePath, process: { env: {} }, exec() {}, console: { @@ -860,6 +876,62 @@ function assertNotFoundResponse(response) { assert.strictEqual(response.destroyedWith, null); } +function mockIsLoopbackRemoteAddress(value) { + return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'; +} + +function mockWriteJsonResponse(res, statusCode, payload, headers = {}) { + const body = JSON.stringify(payload || {}, null, 2); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + ...headers + }); + res.end(body); +} + +function mockIsProtectedWebSurfacePath(requestPath) { + return requestPath === '/' + || requestPath === '/web-ui/index.html' + || requestPath.startsWith('/web-ui/') + || requestPath.startsWith('/res/'); +} + +function mockAssertRequestAuthorized() { + return { ok: true }; +} + +test('extractRequestToken accepts bearer, custom header, and browser-friendly basic auth', () => { + assert.strictEqual(extractRequestToken({ headers: { authorization: 'Bearer web-token' } }), 'web-token'); + assert.strictEqual(extractRequestToken({ headers: { 'x-codexmate-token': ' header-token ' } }), 'header-token'); + assert.strictEqual(extractRequestToken({ headers: { authorization: `Basic ${Buffer.from(':basic-token').toString('base64')}` } }), 'basic-token'); + assert.strictEqual(extractRequestToken({ headers: { authorization: `Basic ${Buffer.from('codexmate:basic-token').toString('base64')}` } }), 'basic-token'); +}); + +test('assertRequestAuthorized returns a basic auth challenge for unauthorized remote clients', () => { + const assertRequestAuthorized = instantiateFunction(assertRequestAuthorizedSource, 'assertRequestAuthorized', { + isLoopbackRemoteAddress: mockIsLoopbackRemoteAddress, + readServerToken() { + return 'expected-token'; + }, + extractRequestToken, + safeTimingEqual(actual, expected) { + return actual === expected; + }, + writeJsonResponse: mockWriteJsonResponse + }); + const response = createMockResponse(); + + const result = assertRequestAuthorized({ + headers: {}, + socket: { remoteAddress: '192.0.2.10' } + }, response); + + assert.deepStrictEqual(result, { ok: false, mode: 'unauthorized' }); + assert.strictEqual(response.statusCode, 401); + assert.strictEqual(response.headers['WWW-Authenticate'], 'Basic realm="codexmate"'); + assert.strictEqual(response.body, '{\n "error": "Unauthorized"\n}'); +}); + test('resolveSkillTarget still falls back to default target when target is omitted', () => { assert.deepStrictEqual(resolveSkillTarget({}), SKILL_TARGETS[0]); assert.deepStrictEqual(resolveSkillTarget({ items: [] }), SKILL_TARGETS[0]); @@ -912,6 +984,91 @@ test('createWebServer redirects bundled index URL to the canonical root URL', () assert.deepStrictEqual(errors, []); }); +test('createWebServer requires auth before serving root page to remote clients', () => { + const calls = []; + const { requestHandler, errors } = createWebServerHarness({ + htmlReader() { + throw new Error('html should not be read before auth'); + }, + authorizeRequest(req, res) { + calls.push(req.url); + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end('{"error":"Unauthorized"}'); + return { ok: false, mode: 'unauthorized' }; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/', socket: { remoteAddress: '192.0.2.10' } }, response); + + assert.strictEqual(response.statusCode, 401); + assert.deepStrictEqual(calls, ['/']); + assert.strictEqual(response.body, '{"error":"Unauthorized"}'); + assert.deepStrictEqual(errors, []); +}); + +test('createWebServer requires auth before serving web-ui assets to remote clients', () => { + const calls = []; + const { requestHandler, errors } = createWebServerHarness({ + authorizeRequest(req, res) { + calls.push(req.url); + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end('{"error":"Unauthorized"}'); + return { ok: false, mode: 'unauthorized' }; + } + }); + const response = createMockResponse(); + + requestHandler({ url: '/web-ui/app.js', socket: { remoteAddress: '192.0.2.10' } }, response); + + assert.strictEqual(response.statusCode, 401); + assert.deepStrictEqual(calls, ['/web-ui/app.js']); + assert.strictEqual(response.body, '{"error":"Unauthorized"}'); + assert.deepStrictEqual(errors, []); +}); + +test('createWebServer serves root page to authorized remote clients', () => { + const calls = []; + const { requestHandler, errors } = createWebServerHarness({ + htmlReader() { + return '