From 49d75119107640ca299b68b5f6f30ab66e4e4c46 Mon Sep 17 00:00:00 2001 From: Stefan Meinecke Date: Wed, 29 Apr 2026 11:37:07 +0200 Subject: [PATCH 1/2] fix(dashboard): validate proxy before account creation in single-add flow Reorder POST /accounts validation - parseProxyUrl + host checks (validateHostFormat vs assertPublicUrlHost per ALLOW_PRIVATE_PROXY_HOSTS) now run before addAccountByKey/addAccountByToken. Prevents orphaned accounts on proxy validation failure. Gate ensureLsForAccount + probeAccount behind !isTestEnv. Frontend: centralize error i18n via App.translateError(code, fallback) - replaces 6 inline `error.${err.message}` lookups. --- src/dashboard/api.js | 23 ++++--- src/dashboard/index.html | 22 ++++--- test/account-add-proxy.test.js | 107 +++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 test/account-add-proxy.test.js diff --git a/src/dashboard/api.js b/src/dashboard/api.js index 6fc65bc2..79512140 100644 --- a/src/dashboard/api.js +++ b/src/dashboard/api.js @@ -123,7 +123,9 @@ async function processWindsurfLogin({ email, password, loginProxy, autoAdd }) { /** * Handle all /dashboard/api/* requests. */ -export async function handleDashboardApi(method, subpath, body, req, res) { +export async function handleDashboardApi(method, subpath, body, req, res, deps = {}) { + const ensureLsForAccountFn = deps.ensureLsForAccount || ensureLsForAccount; + const probeAccountFn = deps.probeAccount || probeAccount; if (method === 'OPTIONS') return json(res, 204, ''); // Auth check (except for auth verification endpoint) @@ -288,16 +290,14 @@ export async function handleDashboardApi(method, subpath, body, req, res) { if (subpath === '/accounts' && method === 'POST') { try { - if (!body.api_key && !body.token) { - return json(res, 400, { error: 'Provide api_key or token' }); - } - + // 1. Validate proxy first (if provided) - fail early before touching account store let parsedProxy = null; if (body.proxy) { parsedProxy = parseProxyUrl(body.proxy); if (!parsedProxy) { return json(res, 400, { error: 'ERR_PROXY_FORMAT_INVALID' }); } + // Validate proxy host (respect ALLOW_PRIVATE_PROXY_HOSTS config) try { if (config.allowPrivateProxyHosts) { await validateHostFormat(parsedProxy.host); @@ -309,10 +309,17 @@ export async function handleDashboardApi(method, subpath, body, req, res) { } } - const account = body.api_key - ? addAccountByKey(body.api_key, body.label) - : await addAccountByToken(body.token, body.label); + // 2. Create account only after proxy validation passes + let account; + if (body.api_key) { + account = addAccountByKey(body.api_key, body.label); + } else if (body.token) { + account = await addAccountByToken(body.token, body.label); + } else { + return json(res, 400, { error: 'Provide api_key or token' }); + } + // 3. Bind proxy to the newly created account if (parsedProxy) { setAccountProxy(account.id, parsedProxy); ensureLsForAccount(account.id).catch(e => log.warn(`LS ensure failed: ${e.message}`)); diff --git a/src/dashboard/index.html b/src/dashboard/index.html index 6fecdd68..31599c14 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -2350,6 +2350,12 @@

控制台登录

I18n.apply(); }, + translateError(errorCode, fallbackKey = 'error.unknown') { + const errKey = errorCode ? `error.${errorCode}` : ''; + if (errKey && I18n.t(errKey) !== errKey) return I18n.t(errKey); + return errorCode || I18n.t(fallbackKey); + }, + async toggleLang() { const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh'; await I18n.setLocale(newLang); @@ -2854,10 +2860,10 @@

控制台登录

input.value = ''; this.loadAccounts(); } else { - this.toast(r.error || I18n.t('toast.addFailed'), 'error'); + this.toast(this.translateError(r.error, 'toast.addFailed'), 'error'); } } catch (err) { - this.toast(err.message, 'error'); + this.toast(this.translateError(err.message, 'error.unknown'), 'error'); } finally { if (btn) btn.disabled = originalDisabled || false; } @@ -3433,7 +3439,7 @@

控制台登录

this.toast(I18n.t('toast.credits.refreshed')); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('toast.credits.refreshFailed'), 'error'); + this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error'); } } catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); } }, @@ -3448,7 +3454,7 @@

控制台登录

this.toast(I18n.t('toast.credits.allRefreshed', { ok: okCount, fail: failCount })); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('toast.credits.refreshFailed'), 'error'); + this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error'); } } catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); } }, @@ -3463,7 +3469,7 @@

控制台登录

this.toast(I18n.t('toast.probeAllComplete', { summary })); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('error.probeFailed'), 'error'); + this.toast(this.translateError(r.error, 'error.probeFailed'), 'error'); } } catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); } }, @@ -3477,7 +3483,7 @@

控制台登录

this.toast(I18n.t('toast.probeComplete', { tier: tierLabel[r.tier] || r.tier })); this.loadAccounts(); } else { - this.toast(r.error || I18n.t('error.probeFailed'), 'error'); + this.toast(this.translateError(r.error, 'error.probeFailed'), 'error'); } } catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); } }, @@ -3603,7 +3609,7 @@

控制台登录

root.remove(); self.loadAccounts(); } else { - self.toast(r.error || I18n.t('error.saveFailed'), 'error'); + self.toast(self.translateError(r.error, 'error.saveFailed'), 'error'); okBtn.disabled = false; cancelBtn.disabled = false; okBtn.textContent = I18n.t('action.save'); } } catch (e) { @@ -3638,7 +3644,7 @@

控制台登录

const body = type === 'api_key' ? { api_key: key, label, proxy } : { token: key, label, proxy }; const r = await this.api('POST', '/accounts', body); if (r.success) { this.toast(I18n.t('account.addSuccess')); document.getElementById('acc-key').value = ''; document.getElementById('acc-label').value = ''; document.getElementById('acc-proxy').value = ''; this.loadAccounts(); } - else this.toast(r.error || I18n.t('toast.addFailed'), 'error'); + else this.toast(this.translateError(r.error, 'toast.addFailed'), 'error'); }, async discoverLocalWindsurf() { diff --git a/test/account-add-proxy.test.js b/test/account-add-proxy.test.js new file mode 100644 index 00000000..319f5527 --- /dev/null +++ b/test/account-add-proxy.test.js @@ -0,0 +1,107 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { config } from '../src/config.js'; +import { configureBindHost, getAccountList, removeAccount } from '../src/auth.js'; +import { handleDashboardApi } from '../src/dashboard/api.js'; +import { getProxyConfig, removeProxy } from '../src/dashboard/proxy-config.js'; + +const originalAllowPrivateProxyHosts = config.allowPrivateProxyHosts; +const createdAccountIds = []; +const testDeps = { + ensureLsForAccount: async () => {}, + probeAccount: async () => ({}), +}; + +function fakeRes() { + return { + statusCode: 0, + body: '', + writeHead(status) { this.statusCode = status; }, + end(chunk) { this.body += chunk ? String(chunk) : ''; }, + json() { return this.body ? JSON.parse(this.body) : null; }, + }; +} + +function accountPayload(extra = {}) { + return { + api_key: `test-key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + label: 'proxy-test', + ...extra, + }; +} + +afterEach(() => { + config.allowPrivateProxyHosts = originalAllowPrivateProxyHosts; + configureBindHost('0.0.0.0'); + while (createdAccountIds.length) { + const id = createdAccountIds.pop(); + removeProxy('account', id); + removeAccount(id); + } +}); + +describe('dashboard account add with proxy validation', () => { + it('rejects invalid proxy format before creating an account', async () => { + configureBindHost('127.0.0.1'); + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'invalid-proxy-url' }), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 400); + assert.equal(res.json().error, 'ERR_PROXY_FORMAT_INVALID'); + assert.equal(getAccountList().length, before); + }); + + it('rejects private proxy hosts by default before creating an account', async () => { + configureBindHost('127.0.0.1'); + config.allowPrivateProxyHosts = false; + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'http://127.0.0.1:8080' }), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 400); + assert.match(String(res.json().error || ''), /ERR_PROXY_PRIVATE_IP|ERR_PROXY_PRIVATE_HOST/); + assert.equal(getAccountList().length, before); + }); + + it('creates account and binds proxy when proxy is valid', async () => { + configureBindHost('127.0.0.1'); + config.allowPrivateProxyHosts = true; + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'http://127.0.0.1:8080' }), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 200); + assert.equal(res.json().success, true); + const accountId = res.json().account?.id; + assert.ok(accountId); + createdAccountIds.push(accountId); + assert.equal(getAccountList().length, before + 1); + assert.deepEqual(getProxyConfig().perAccount[accountId], { + type: 'http', + host: '127.0.0.1', + port: 8080, + username: '', + password: '', + }); + }); + + it('creates account without proxy binding when proxy is omitted', async () => { + configureBindHost('127.0.0.1'); + const before = getAccountList().length; + + const res = fakeRes(); + await handleDashboardApi('POST', '/accounts', accountPayload(), { headers: {} }, res, testDeps); + + assert.equal(res.statusCode, 200); + assert.equal(res.json().success, true); + const accountId = res.json().account?.id; + assert.ok(accountId); + createdAccountIds.push(accountId); + assert.equal(getAccountList().length, before + 1); + assert.equal(getProxyConfig().perAccount[accountId], undefined); + }); +}); From 424bd3bc4e315d55beb7793662ef191edcb28fd8 Mon Sep 17 00:00:00 2001 From: Stefan Meinecke Date: Wed, 29 Apr 2026 12:01:50 +0200 Subject: [PATCH 2/2] fix(dashboard): validate credentials before proxy checks in POST /accounts Move api_key/token presence check to top of POST /accounts handler - runs before proxy validation + account creation. Prevents proxy network checks when credentials are missing. Simplify account creation to ternary expression (api_key vs token path). No functional change to validation order from #90 (proxy still validated before account creation). --- src/dashboard/api.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/dashboard/api.js b/src/dashboard/api.js index 79512140..668cf172 100644 --- a/src/dashboard/api.js +++ b/src/dashboard/api.js @@ -290,6 +290,11 @@ export async function handleDashboardApi(method, subpath, body, req, res, deps = if (subpath === '/accounts' && method === 'POST') { try { + // Validate required credentials before any proxy validation/network checks. + if (!body.api_key && !body.token) { + return json(res, 400, { error: 'Provide api_key or token' }); + } + // 1. Validate proxy first (if provided) - fail early before touching account store let parsedProxy = null; if (body.proxy) { @@ -310,14 +315,9 @@ export async function handleDashboardApi(method, subpath, body, req, res, deps = } // 2. Create account only after proxy validation passes - let account; - if (body.api_key) { - account = addAccountByKey(body.api_key, body.label); - } else if (body.token) { - account = await addAccountByToken(body.token, body.label); - } else { - return json(res, 400, { error: 'Provide api_key or token' }); - } + const account = body.api_key + ? addAccountByKey(body.api_key, body.label) + : await addAccountByToken(body.token, body.label); // 3. Bind proxy to the newly created account if (parsedProxy) {