diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 831d5b6..cb1f97a 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "botmaker-dashboard", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "botmaker-dashboard", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" @@ -102,6 +102,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -458,6 +459,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -481,6 +483,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1698,8 +1701,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1773,6 +1775,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1784,6 +1787,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -1833,6 +1837,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2177,6 +2182,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2237,7 +2243,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2326,6 +2331,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2633,8 +2639,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2812,6 +2817,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3587,6 +3593,7 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -3751,7 +3758,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4080,6 +4086,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4132,7 +4139,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4157,6 +4163,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4169,6 +4176,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4182,8 +4190,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -4674,6 +4681,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4753,6 +4761,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5341,6 +5350,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", @@ -5864,6 +5874,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/dashboard/src/api.test.ts b/dashboard/src/api.test.ts index ea1cb5d..2b40148 100644 --- a/dashboard/src/api.test.ts +++ b/dashboard/src/api.test.ts @@ -14,19 +14,26 @@ import { addProxyKey, deleteProxyKey, fetchProxyHealth, + setAdminToken, + clearAdminToken, } from './api'; // Mock fetch const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); +const TEST_TOKEN = 'test-admin-token'; +const AUTH_HEADER = { Authorization: `Bearer ${TEST_TOKEN}` }; + describe('API Client', () => { beforeEach(() => { mockFetch.mockReset(); + setAdminToken(TEST_TOKEN); }); afterEach(() => { vi.clearAllMocks(); + clearAdminToken(); }); describe('fetchBots', () => { @@ -44,7 +51,7 @@ describe('API Client', () => { const result = await fetchBots(); expect(result).toEqual(bots); - expect(mockFetch).toHaveBeenCalledWith('/api/bots'); + expect(mockFetch).toHaveBeenCalledWith('/api/bots', { headers: AUTH_HEADER }); }); it('should throw on error response', async () => { @@ -80,7 +87,7 @@ describe('API Client', () => { const result = await fetchBot('bot-1'); expect(result).toEqual(bot); - expect(mockFetch).toHaveBeenCalledWith('/api/bots/bot-1'); + expect(mockFetch).toHaveBeenCalledWith('/api/bots/bot-1', { headers: AUTH_HEADER }); }); }); @@ -108,7 +115,7 @@ describe('API Client', () => { expect(result).toEqual(createdBot); expect(mockFetch).toHaveBeenCalledWith('/api/bots', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...AUTH_HEADER }, body: JSON.stringify(input), }); }); @@ -124,6 +131,7 @@ describe('API Client', () => { await expect(deleteBot('bot-1')).resolves.toBeUndefined(); expect(mockFetch).toHaveBeenCalledWith('/api/bots/bot-1', { method: 'DELETE', + headers: AUTH_HEADER, }); }); }); @@ -138,6 +146,7 @@ describe('API Client', () => { await expect(startBot('bot-1')).resolves.toBeUndefined(); expect(mockFetch).toHaveBeenCalledWith('/api/bots/bot-1/start', { method: 'POST', + headers: AUTH_HEADER, }); }); }); @@ -152,6 +161,7 @@ describe('API Client', () => { await expect(stopBot('bot-1')).resolves.toBeUndefined(); expect(mockFetch).toHaveBeenCalledWith('/api/bots/bot-1/stop', { method: 'POST', + headers: AUTH_HEADER, }); }); }); @@ -170,7 +180,7 @@ describe('API Client', () => { const result = await fetchContainerStats(); expect(result).toEqual(stats); - expect(mockFetch).toHaveBeenCalledWith('/api/stats'); + expect(mockFetch).toHaveBeenCalledWith('/api/stats', { headers: AUTH_HEADER }); }); }); @@ -191,7 +201,7 @@ describe('API Client', () => { const result = await fetchOrphans(); expect(result).toEqual(orphans); - expect(mockFetch).toHaveBeenCalledWith('/api/admin/orphans'); + expect(mockFetch).toHaveBeenCalledWith('/api/admin/orphans', { headers: AUTH_HEADER }); }); }); @@ -214,6 +224,7 @@ describe('API Client', () => { expect(result).toEqual(report); expect(mockFetch).toHaveBeenCalledWith('/api/admin/cleanup', { method: 'POST', + headers: AUTH_HEADER, }); }); }); @@ -248,7 +259,7 @@ describe('API Client', () => { const result = await fetchProxyKeys(); expect(result).toEqual(keys); - expect(mockFetch).toHaveBeenCalledWith('/api/proxy/keys'); + expect(mockFetch).toHaveBeenCalledWith('/api/proxy/keys', { headers: AUTH_HEADER }); }); }); @@ -268,7 +279,7 @@ describe('API Client', () => { expect(result.id).toBe('new-key-id'); expect(mockFetch).toHaveBeenCalledWith('/api/proxy/keys', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...AUTH_HEADER }, body: JSON.stringify({ vendor: 'openai', secret: 'sk-test', @@ -288,6 +299,7 @@ describe('API Client', () => { await expect(deleteProxyKey('key-123')).resolves.toBeUndefined(); expect(mockFetch).toHaveBeenCalledWith('/api/proxy/keys/key-123', { method: 'DELETE', + headers: AUTH_HEADER, }); }); }); @@ -309,7 +321,7 @@ describe('API Client', () => { const result = await fetchProxyHealth(); expect(result).toEqual(health); - expect(mockFetch).toHaveBeenCalledWith('/api/proxy/health'); + expect(mockFetch).toHaveBeenCalledWith('/api/proxy/health', { headers: AUTH_HEADER }); }); }); }); diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 33c88ac..43d4a2a 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -2,7 +2,40 @@ import type { Bot, CreateBotInput, ContainerStats, OrphanReport, CleanupReport, const API_BASE = '/api'; +let adminToken: string | null = null; + +export function setAdminToken(token: string): void { + adminToken = token; + localStorage.setItem('admin_token', token); +} + +export function getAdminToken(): string | null { + if (!adminToken) { + adminToken = localStorage.getItem('admin_token'); + } + return adminToken; +} + +export function clearAdminToken(): void { + adminToken = null; + localStorage.removeItem('admin_token'); +} + +function getAuthHeaders(): HeadersInit { + const token = getAdminToken(); + if (!token) { + throw new Error('Not authenticated. Please provide an admin token.'); + } + return { + Authorization: `Bearer ${token}`, + }; +} + async function handleResponse(response: Response): Promise { + if (response.status === 401 || response.status === 403) { + clearAdminToken(); + throw new Error('Authentication failed. Please re-enter your admin token.'); + } if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `HTTP error ${response.status}`); @@ -11,20 +44,24 @@ async function handleResponse(response: Response): Promise { } export async function fetchBots(): Promise { - const response = await fetch(`${API_BASE}/bots`); + const response = await fetch(`${API_BASE}/bots`, { + headers: getAuthHeaders(), + }); const data = await handleResponse<{ bots: Bot[] }>(response); return data.bots; } export async function fetchBot(hostname: string): Promise { - const response = await fetch(`${API_BASE}/bots/${hostname}`); + const response = await fetch(`${API_BASE}/bots/${hostname}`, { + headers: getAuthHeaders(), + }); return handleResponse(response); } export async function createBot(input: CreateBotInput): Promise { const response = await fetch(`${API_BASE}/bots`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify(input), }); return handleResponse(response); @@ -33,6 +70,7 @@ export async function createBot(input: CreateBotInput): Promise { export async function deleteBot(hostname: string): Promise { const response = await fetch(`${API_BASE}/bots/${hostname}`, { method: 'DELETE', + headers: getAuthHeaders(), }); await handleResponse<{ success: boolean }>(response); } @@ -40,6 +78,7 @@ export async function deleteBot(hostname: string): Promise { export async function startBot(hostname: string): Promise { const response = await fetch(`${API_BASE}/bots/${hostname}/start`, { method: 'POST', + headers: getAuthHeaders(), }); await handleResponse<{ success: boolean }>(response); } @@ -47,24 +86,30 @@ export async function startBot(hostname: string): Promise { export async function stopBot(hostname: string): Promise { const response = await fetch(`${API_BASE}/bots/${hostname}/stop`, { method: 'POST', + headers: getAuthHeaders(), }); await handleResponse<{ success: boolean }>(response); } export async function fetchContainerStats(): Promise { - const response = await fetch(`${API_BASE}/stats`); + const response = await fetch(`${API_BASE}/stats`, { + headers: getAuthHeaders(), + }); const data = await handleResponse<{ stats: ContainerStats[] }>(response); return data.stats; } export async function fetchOrphans(): Promise { - const response = await fetch(`${API_BASE}/admin/orphans`); + const response = await fetch(`${API_BASE}/admin/orphans`, { + headers: getAuthHeaders(), + }); return handleResponse(response); } export async function runCleanup(): Promise { const response = await fetch(`${API_BASE}/admin/cleanup`, { method: 'POST', + headers: getAuthHeaders(), }); return handleResponse(response); } @@ -74,9 +119,10 @@ export async function checkHealth(): Promise<{ status: string; timestamp: string return handleResponse<{ status: string; timestamp: string }>(response); } -// Proxy key management export async function fetchProxyKeys(): Promise { - const response = await fetch(`${API_BASE}/proxy/keys`); + const response = await fetch(`${API_BASE}/proxy/keys`, { + headers: getAuthHeaders(), + }); const data = await handleResponse<{ keys: ProxyKey[] }>(response); return data.keys; } @@ -84,7 +130,7 @@ export async function fetchProxyKeys(): Promise { export async function addProxyKey(input: AddKeyInput): Promise<{ id: string }> { const response = await fetch(`${API_BASE}/proxy/keys`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify(input), }); return handleResponse<{ id: string }>(response); @@ -93,11 +139,14 @@ export async function addProxyKey(input: AddKeyInput): Promise<{ id: string }> { export async function deleteProxyKey(id: string): Promise { const response = await fetch(`${API_BASE}/proxy/keys/${id}`, { method: 'DELETE', + headers: getAuthHeaders(), }); await handleResponse<{ ok: boolean }>(response); } export async function fetchProxyHealth(): Promise { - const response = await fetch(`${API_BASE}/proxy/health`); + const response = await fetch(`${API_BASE}/proxy/health`, { + headers: getAuthHeaders(), + }); return handleResponse(response); } diff --git a/package-lock.json b/package-lock.json index 5277f7e..242fe6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "botmaker", - "version": "0.5.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "botmaker", - "version": "0.5.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@fastify/basic-auth": "^6.0.0", + "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "better-sqlite3": "^11.6.0", "dockerode": "^4.0.4", @@ -827,6 +829,26 @@ ], "license": "MIT" }, + "node_modules/@fastify/helmet": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.0.2.tgz", + "integrity": "sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "helmet": "^8.0.0" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -866,6 +888,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@fastify/send": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", @@ -1686,6 +1729,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2055,6 +2099,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2778,6 +2823,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3338,6 +3384,15 @@ "node": ">=8" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4023,6 +4078,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5028,6 +5084,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5101,6 +5158,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5614,6 +5672,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/package.json b/package.json index 5ceccd4..3bd04fe 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ }, "dependencies": { "@fastify/basic-auth": "^6.0.0", + "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^8.0.0", "better-sqlite3": "^11.6.0", "dockerode": "^4.0.4", diff --git a/proxy/package-lock.json b/proxy/package-lock.json index fbaf2c3..4bd8c01 100644 --- a/proxy/package-lock.json +++ b/proxy/package-lock.json @@ -8,6 +8,8 @@ "name": "keyring-proxy", "version": "1.0.0", "dependencies": { + "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^10.3.0", "better-sqlite3": "^11.6.0", "fastify": "^5.1.0", "uuid": "^11.0.0" @@ -632,6 +634,26 @@ ], "license": "MIT" }, + "node_modules/@fastify/helmet": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.0.2.tgz", + "integrity": "sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "helmet": "^8.0.0" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -671,6 +693,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -738,6 +781,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2229,6 +2281,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2346,6 +2414,15 @@ "node": ">=8" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3546,6 +3623,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3685,6 +3763,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", diff --git a/proxy/package.json b/proxy/package.json index 7c2cc97..0ebc7dd 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -13,6 +13,8 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^10.3.0", "better-sqlite3": "^11.6.0", "fastify": "^5.1.0", "uuid": "^11.0.0" diff --git a/proxy/src/index.ts b/proxy/src/index.ts index c0e2b4c..544e863 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -1,4 +1,6 @@ import Fastify from 'fastify'; +import fastifyRateLimit from '@fastify/rate-limit'; +import fastifyHelmet from '@fastify/helmet'; import { loadConfig } from './config.js'; import { ProxyDatabase } from './db/index.js'; import { KeyringService } from './services/keyring.js'; @@ -16,6 +18,11 @@ async function main(): Promise { // Create admin server const adminApp = Fastify({ logger: true }); + await adminApp.register(fastifyHelmet); + await adminApp.register(fastifyRateLimit, { + max: 100, + timeWindow: '1 minute', + }); adminApp.addContentTypeParser( 'application/json', { parseAs: 'string' }, @@ -31,6 +38,11 @@ async function main(): Promise { // Create data plane server const dataApp = Fastify({ logger: true }); + await dataApp.register(fastifyHelmet); + await dataApp.register(fastifyRateLimit, { + max: 1000, + timeWindow: '1 minute', + }); // Parse JSON and raw bodies for proxy dataApp.addContentTypeParser( diff --git a/proxy/src/routes/admin.ts b/proxy/src/routes/admin.ts index fcb3a8d..358c5da 100644 --- a/proxy/src/routes/admin.ts +++ b/proxy/src/routes/admin.ts @@ -1,9 +1,22 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { v4 as uuidv4 } from 'uuid'; +import { timingSafeEqual } from 'node:crypto'; import type { ProxyDatabase } from '../db/index.js'; import { encrypt, generateToken, hashToken } from '../crypto/encryption.js'; import { VENDOR_CONFIGS } from '../types.js'; +function safeCompare(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + const maxLen = Math.max(aBuf.length, bBuf.length); + const aPadded = Buffer.alloc(maxLen); + const bPadded = Buffer.alloc(maxLen); + aBuf.copy(aPadded); + bBuf.copy(bPadded); + const equal = timingSafeEqual(aPadded, bPadded); + return equal && aBuf.length === bBuf.length; +} + interface AddKeyBody { vendor: string; secret: string; @@ -23,7 +36,6 @@ export function registerAdminRoutes( masterKey: Buffer, adminToken: string ): void { - // Auth hook app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) { @@ -32,13 +44,12 @@ export function registerAdminRoutes( } const token = auth.slice(7); - if (token !== adminToken) { + if (!safeCompare(token, adminToken)) { reply.status(403).send({ error: 'Invalid admin token' }); return; } }); - // Health check app.get('/admin/health', async () => { return { status: 'ok', @@ -47,7 +58,6 @@ export function registerAdminRoutes( }; }); - // Keys management app.post('/admin/keys', async (req: FastifyRequest, reply: FastifyReply) => { const body = req.body as AddKeyBody; @@ -85,7 +95,6 @@ export function registerAdminRoutes( return { ok: true }; }); - // Bots management app.post('/admin/bots', async (req: FastifyRequest, reply: FastifyReply) => { const body = req.body as AddBotBody; @@ -94,7 +103,6 @@ export function registerAdminRoutes( return; } - // Check if bot already exists const existing = db.getBot(body.botId); if (existing) { reply.status(409).send({ error: 'Bot already registered' }); diff --git a/proxy/src/routes/proxy.ts b/proxy/src/routes/proxy.ts index 9b4c21b..7dd2613 100644 --- a/proxy/src/routes/proxy.ts +++ b/proxy/src/routes/proxy.ts @@ -40,7 +40,15 @@ export function registerProxyRoutes( } // Parse bot tags from JSON - const botTags: string[] | null = bot.tags ? JSON.parse(bot.tags) : null; + let botTags: string[] | null = null; + if (bot.tags) { + try { + botTags = JSON.parse(bot.tags); + } catch { + reply.status(500).send({ error: 'Invalid bot tags configuration' }); + return; + } + } // Select API key for vendor with tag-based routing const keySelection = keyring.selectKeyForBot(vendor, botTags); @@ -49,7 +57,6 @@ export function registerProxyRoutes( return; } - // Build headers from request const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { if (typeof value === 'string') { @@ -59,16 +66,11 @@ export function registerProxyRoutes( } } - // Get request body let body: Buffer | null = null; if (req.body) { - if (Buffer.isBuffer(req.body)) { - body = req.body; - } else if (typeof req.body === 'string') { - body = Buffer.from(req.body, 'utf8'); - } else { - body = Buffer.from(JSON.stringify(req.body), 'utf8'); - } + body = Buffer.isBuffer(req.body) + ? req.body + : Buffer.from(typeof req.body === 'string' ? req.body : JSON.stringify(req.body), 'utf8'); } // Forward to upstream diff --git a/proxy/src/services/upstream.ts b/proxy/src/services/upstream.ts index 03b183d..ef775e9 100644 --- a/proxy/src/services/upstream.ts +++ b/proxy/src/services/upstream.ts @@ -3,6 +3,8 @@ import type { IncomingMessage } from 'http'; import type { FastifyRequest, FastifyReply } from 'fastify'; import type { VendorConfig } from '../types.js'; +const REQUEST_TIMEOUT_MS = 120000; + export interface UpstreamRequest { vendorConfig: VendorConfig; path: string; @@ -48,6 +50,7 @@ export async function forwardToUpstream( path: upstreamPath, method, headers: upstreamHeaders, + timeout: REQUEST_TIMEOUT_MS, }; const proxyReq = https.request(options, (proxyRes: IncomingMessage) => { @@ -96,6 +99,11 @@ export async function forwardToUpstream( reject(err); }); + proxyReq.on('timeout', () => { + proxyReq.destroy(); + reject(new Error('Upstream request timed out')); + }); + if (body) { proxyReq.write(body); } diff --git a/src/proxy/client.ts b/src/proxy/client.ts index f1df567..3ea6461 100644 --- a/src/proxy/client.ts +++ b/src/proxy/client.ts @@ -1,11 +1,13 @@ -/** - * Proxy Client - * - * Client for communicating with the keyring proxy admin API. - */ - import { getConfig } from '../config.js'; +const REQUEST_TIMEOUT_MS = 30000; + +function fetchWithTimeout(url: string, options: RequestInit = {}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { controller.abort(); }, REQUEST_TIMEOUT_MS); + return fetch(url, { ...options, signal: controller.signal }).finally(() => { clearTimeout(timeoutId); }); +} + export interface ProxyConfig { adminUrl: string; adminToken: string; @@ -36,10 +38,10 @@ export interface AddKeyInput { tag?: string; } -/** - * Get proxy configuration from environment. - * Returns null if proxy is not configured. - */ +function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} + export function getProxyConfig(): ProxyConfig | null { const config = getConfig(); @@ -53,15 +55,10 @@ export function getProxyConfig(): ProxyConfig | null { }; } -/** - * Check if proxy is available and healthy. - */ export async function isProxyHealthy(proxyConfig: ProxyConfig): Promise { try { - const response = await fetch(`${proxyConfig.adminUrl}/admin/health`, { - headers: { - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/health`, { + headers: authHeaders(proxyConfig.adminToken), }); return response.ok; } catch { @@ -69,22 +66,15 @@ export async function isProxyHealthy(proxyConfig: ProxyConfig): Promise } } -/** - * Register a bot with the proxy. - * Returns the bot token to use for proxy authentication. - */ export async function registerBotWithProxy( proxyConfig: ProxyConfig, botId: string, hostname: string, tags?: string[] ): Promise { - const response = await fetch(`${proxyConfig.adminUrl}/admin/bots`, { + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/bots`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + headers: { 'Content-Type': 'application/json', ...authHeaders(proxyConfig.adminToken) }, body: JSON.stringify({ botId, hostname, tags }), }); @@ -96,18 +86,13 @@ export async function registerBotWithProxy( return response.json() as Promise; } -/** - * Revoke a bot's access to the proxy. - */ export async function revokeBotFromProxy( proxyConfig: ProxyConfig, botId: string ): Promise { - const response = await fetch(`${proxyConfig.adminUrl}/admin/bots/${botId}`, { + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/bots/${botId}`, { method: 'DELETE', - headers: { - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + headers: authHeaders(proxyConfig.adminToken), }); if (!response.ok && response.status !== 404) { @@ -116,16 +101,11 @@ export async function revokeBotFromProxy( } } -/** - * Get list of vendors that have keys configured in the proxy. - */ export async function getAvailableVendors( proxyConfig: ProxyConfig ): Promise { - const response = await fetch(`${proxyConfig.adminUrl}/admin/keys`, { - headers: { - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/keys`, { + headers: authHeaders(proxyConfig.adminToken), }); if (!response.ok) { @@ -133,20 +113,14 @@ export async function getAvailableVendors( } const keys = await response.json() as ProxyKey[]; - const vendors = new Set(keys.map(k => k.vendor)); - return Array.from(vendors); + return [...new Set(keys.map(k => k.vendor))]; } -/** - * List all API keys from the proxy (without secrets). - */ export async function listProxyKeys( proxyConfig: ProxyConfig ): Promise { - const response = await fetch(`${proxyConfig.adminUrl}/admin/keys`, { - headers: { - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/keys`, { + headers: authHeaders(proxyConfig.adminToken), }); if (!response.ok) { @@ -156,19 +130,13 @@ export async function listProxyKeys( return response.json() as Promise; } -/** - * Add a new API key to the proxy. - */ export async function addProxyKey( proxyConfig: ProxyConfig, input: AddKeyInput ): Promise<{ id: string }> { - const response = await fetch(`${proxyConfig.adminUrl}/admin/keys`, { + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/keys`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + headers: { 'Content-Type': 'application/json', ...authHeaders(proxyConfig.adminToken) }, body: JSON.stringify(input), }); @@ -180,18 +148,13 @@ export async function addProxyKey( return response.json() as Promise<{ id: string }>; } -/** - * Delete an API key from the proxy. - */ export async function deleteProxyKey( proxyConfig: ProxyConfig, keyId: string ): Promise { - const response = await fetch(`${proxyConfig.adminUrl}/admin/keys/${keyId}`, { + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/keys/${keyId}`, { method: 'DELETE', - headers: { - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + headers: authHeaders(proxyConfig.adminToken), }); if (!response.ok && response.status !== 404) { @@ -200,16 +163,11 @@ export async function deleteProxyKey( } } -/** - * Get proxy health status. - */ export async function getProxyHealth( proxyConfig: ProxyConfig ): Promise { - const response = await fetch(`${proxyConfig.adminUrl}/admin/health`, { - headers: { - Authorization: `Bearer ${proxyConfig.adminToken}`, - }, + const response = await fetchWithTimeout(`${proxyConfig.adminUrl}/admin/health`, { + headers: authHeaders(proxyConfig.adminToken), }); if (!response.ok) { diff --git a/src/server.ts b/src/server.ts index 44f79b0..fb3001e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,10 @@ -/** - * Fastify Server - * - * API routes for bot management. - */ - -import Fastify, { FastifyInstance } from 'fastify'; +import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import fastifyStatic from '@fastify/static'; +import fastifyRateLimit from '@fastify/rate-limit'; +import fastifyHelmet from '@fastify/helmet'; import { join, resolve } from 'node:path'; import { existsSync } from 'node:fs'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; import { getConfig } from './config.js'; import { initDb } from './db/index.js'; @@ -40,6 +36,18 @@ import { const docker = new DockerService(); +function safeCompare(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + const maxLen = Math.max(aBuf.length, bBuf.length); + const aPadded = Buffer.alloc(maxLen); + const bPadded = Buffer.alloc(maxLen); + aBuf.copy(aPadded); + bBuf.copy(bPadded); + const equal = timingSafeEqual(aPadded, bPadded); + return equal && aBuf.length === bBuf.length; +} + type SessionScope = 'user' | 'channel' | 'global'; interface CreateBotBody { @@ -65,11 +73,6 @@ interface CreateBotBody { tags?: string[]; } -/** - * Resolve host paths for Docker bind mounts. - * If volume names are configured, inspect them to get actual host paths. - * Otherwise, fall back to using the configured directories directly. - */ async function resolveHostPaths(config: ReturnType): Promise<{ hostDataDir: string; hostSecretsDir: string; @@ -87,9 +90,6 @@ async function resolveHostPaths(config: ReturnType): Promise<{ return { hostDataDir, hostSecretsDir }; } -/** - * Build and configure the Fastify server. - */ export async function buildServer(): Promise { const config = getConfig(); @@ -103,6 +103,44 @@ export async function buildServer(): Promise { logger: true, }); + // Register security headers + await server.register(fastifyHelmet, { + contentSecurityPolicy: false, + }); + + // Register rate limiting + await server.register(fastifyRateLimit, { + max: 100, + timeWindow: '1 minute', + }); + + // Authentication middleware for API routes + const adminToken = process.env.ADMIN_TOKEN; + if (!adminToken) { + throw new Error('ADMIN_TOKEN environment variable is required'); + } + + server.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => { + if (request.url === '/health') { + return; + } + if (!request.url.startsWith('/api/')) { + return; + } + + const auth = request.headers.authorization; + if (!auth?.startsWith('Bearer ')) { + reply.code(401).send({ error: 'Missing authorization' }); + return; + } + + const token = auth.slice(7); + if (!safeCompare(token, adminToken)) { + reply.code(403).send({ error: 'Invalid admin token' }); + return; + } + }); + // Run startup reconciliation const reconciliation = new ReconciliationService(docker, config.dataDir, server.log); const report = await reconciliation.reconcileOnStartup(); @@ -216,12 +254,15 @@ export async function buildServer(): Promise { proxyToken = registration.token; } - - // Store channel tokens for (const channel of body.channels) { - const tokenName = channel.channelType === 'telegram' ? 'TELEGRAM_TOKEN' - : channel.channelType === 'discord' ? 'DISCORD_TOKEN' - : `${channel.channelType.toUpperCase()}_TOKEN`; + let tokenName: string; + if (channel.channelType === 'telegram') { + tokenName = 'TELEGRAM_TOKEN'; + } else if (channel.channelType === 'discord') { + tokenName = 'DISCORD_TOKEN'; + } else { + tokenName = `${channel.channelType.toUpperCase()}_TOKEN`; + } writeSecret(bot.hostname, tokenName, channel.token); } @@ -428,7 +469,7 @@ export async function buildServer(): Promise { }); // Proxy key management endpoints - server.get('/api/proxy/keys', async (request, reply) => { + server.get('/api/proxy/keys', async (_request, reply) => { const proxyConfig = getProxyConfig(); if (!proxyConfig) { reply.code(503); @@ -483,7 +524,7 @@ export async function buildServer(): Promise { } }); - server.get('/api/proxy/health', async (request, reply) => { + server.get('/api/proxy/health', async (_request, reply) => { const proxyConfig = getProxyConfig(); if (!proxyConfig) { reply.code(503);