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
1 change: 1 addition & 0 deletions src/dashboard/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,7 @@
"ERR_IDTOKEN_REQUIRED": "ID token is required",
"ERR_PROXY_PRIVATE_IP": "Proxy cannot point to private/local address",
"ERR_PROXY_HTTP_ERROR": "Proxy returned HTTP error",
"ERR_PROXY_PRIVATE_HOST": "Private/local host is not allowed",
"ERR_CONNECTION_FAILED": "Connection failed",
"ERR_TIMEOUT": "Timeout (10s)",
"ERR_TLS_TUNNEL_ERROR": "TLS tunnel established but returned abnormal content",
Expand Down
1 change: 1 addition & 0 deletions src/dashboard/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,7 @@
"ERR_IDTOKEN_REQUIRED": "缺少 idToken",
"ERR_PROXY_PRIVATE_IP": "代理地址不能指向内网/本机",
"ERR_PROXY_HTTP_ERROR": "代理返回 HTTP 错误",
"ERR_PROXY_PRIVATE_HOST": "内网/本地主机不允许",
"ERR_CONNECTION_FAILED": "连接失败",
"ERR_TIMEOUT": "超时(10s)",
"ERR_TLS_TUNNEL_ERROR": "TLS 隧道建立但返回内容异常",
Expand Down
57 changes: 42 additions & 15 deletions src/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1521,8 +1521,8 @@
</div>
</nav>
<div class="footer">
<button class="btn btn-ghost btn-xs" onclick="App.toggleLang()" title="Switch language" style="margin-right:8px">
<span id="lang-indicator"></span> / EN
<button id="lang-toggle-btn" class="btn btn-ghost btn-xs" onclick="App.toggleLang()" title="Switch language" style="margin-right:8px">
<span id="lang-indicator">中文</span>
</button>
<span>© Windsurf</span>
<span class="ver" id="sidebar-ver" data-i18n-title="footer.version" title="版本">v1.2.0</span>
Expand Down Expand Up @@ -2295,8 +2295,11 @@ <h3 data-i18n="login.title">控制台登录</h3>
document.documentElement.lang = this.locale === 'zh' || this.locale === 'zh-CN' ? 'zh-CN' : 'en';

// Update language indicator
const zhMode = this.locale === 'zh' || this.locale === 'zh-CN';
const indicator = document.getElementById('lang-indicator');
if (indicator) indicator.textContent = (this.locale === 'zh' || this.locale === 'zh-CN') ? '中' : 'EN';
if (indicator) indicator.textContent = zhMode ? '中文' : 'English';
const toggleBtn = document.getElementById('lang-toggle-btn');
if (toggleBtn) toggleBtn.title = zhMode ? 'Switch to English' : '切换到中文';
},

async setLocale(lang) {
Expand Down Expand Up @@ -2336,10 +2339,24 @@ <h3 data-i18n="login.title">控制台登录</h3>
I18n.apply();
},

toggleLang() {
async toggleLang() {
const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh';
I18n.setLocale(newLang);
await I18n.setLocale(newLang);
this.lang = newLang;
this.refreshActivePanelI18n();
},

refreshActivePanelI18n() {
const activePanelId = document.querySelector('.panel.active')?.id?.replace(/^p-/, '');
if (!activePanelId) return;
const loaders = {
overview: 'loadOverview', 'windsurf-login': 'loadWindsurfLogin',
accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
logs: 'loadLogs', stats: 'loadStats', bans: 'loadBans',
experimental: 'loadExperimental', credits: 'loadCredits',
};
const loader = loaders[activePanelId];
if (loader && typeof this[loader] === 'function') this[loader]();
},

// Register a polling loader. Guarantees exactly one timer per key even if
Expand Down Expand Up @@ -2705,7 +2722,9 @@ <h3 data-i18n="login.title">控制台登录</h3>
} catch (err) {
status.style.color = '#ef4444';
status.textContent = I18n.t('oauth.status.loginFailed', { label, error: err.message });
this.toast(I18n.t('toast.loginFailed', { label, error: err.message }), 'error');
const errKey = err.message ? `error.${err.message}` : null;
const translatedErr = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message;
this.toast(I18n.t('toast.loginFailed', { label, error: translatedErr }), 'error');
} finally {
if (btn) btn.disabled = false;
}
Expand Down Expand Up @@ -2778,13 +2797,15 @@ <h3 data-i18n="login.title">控制台登录</h3>
</div>`);
return;
}
const errKey = r.error ? `error.${r.error}` : null;
const errMsg = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown'));
this.showWindsurfLoginResult(`
<div class="section-header">
<div>
<div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div>
</div>
</div>
<div class="section-body"><p class="text-sm">${this.esc(r.error || I18n.t('error.unknown'))}</p>${this.getWindsurfLoginFailActions(r)}</div>`);
<div class="section-body"><p class="text-sm">${this.esc(errMsg)}</p>${this.getWindsurfLoginFailActions(r)}</div>`);
},

renderBatchWindsurfLoginResult(results, autoAdd) {
Expand All @@ -2808,7 +2829,7 @@ <h3 data-i18n="login.title">控制台登录</h3>
<td><span class="badge ${r.success ? 'active' : 'error'}">${r.success ? I18n.t('batch.success') : I18n.t('batch.fail')}</span></td>
<td class="text-sm">${r.success
? `<span class="break-all"><code>${this.esc(r.apiKey_masked || (r.apiKey ? r.apiKey.slice(0, 16) + '...' : '-'))}</code>${r.account ? ` · ${I18n.t('table.header.account')} ${this.esc(r.account.id)}` : ''}</span>`
: `<span style="color:var(--error)">${this.esc(r.error || I18n.t('error.unknown'))}</span>`}</td>
: (() => { const errKey = r.error ? `error.${r.error}` : null; return `<span style="color:var(--error)">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown')))}</span>`; })()}</td>
</tr>
`).join('')}
</tbody>
Expand Down Expand Up @@ -2880,11 +2901,13 @@ <h3 data-i18n="login.title">控制台登录</h3>
} else {
entry.status = 'error: ' + (r.error || 'unknown');
this.renderSingleWindsurfLoginResult(r, autoAdd);
this.toast(r.error || I18n.t('toast.loginFailed'), 'error');
const errKey = r.error ? `error.${r.error}` : null;
this.toast((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('toast.loginFailed')), 'error');
}
} catch (err) {
entry.status = 'error: ' + err.message;
this.toast(err.message, 'error');
const errKey = err.message ? `error.${err.message}` : null;
this.toast((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message, 'error');
}

this.pushLoginHistory(entry);
Expand Down Expand Up @@ -2912,12 +2935,14 @@ <h3 data-i18n="login.title">控制台登录</h3>
try {
const r = await this.api('POST', '/batch-import', { text: input, autoAdd });
if (!r.success || !Array.isArray(r.results)) {
const errKey = r.error ? `error.${r.error}` : null;
this.showWindsurfLoginResult(`
<div class="section-header">
<div><div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div></div>
</div>
<div class="section-body"><p class="text-sm">${this.esc(r.error || I18n.t('error.unknown'))}</p></div>`);
return this.toast(r.error || I18n.t('batch.importFailed'), 'error');
<div class="section-body"><p class="text-sm">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : (r.error || I18n.t('error.unknown')))}</p></div>`);
const translatedErr = errKey && I18n.t(errKey) !== errKey ? I18n.t(errKey) : r.error;
return this.toast(translatedErr || I18n.t('batch.importFailed'), 'error');
}

this.renderBatchWindsurfLoginResult(r.results, autoAdd);
Expand All @@ -2934,12 +2959,14 @@ <h3 data-i18n="login.title">控制台登录</h3>
document.getElementById('wl-batch-input').value = '';
}
} catch (err) {
const errKey = err.message ? `error.${err.message}` : null;
this.showWindsurfLoginResult(`
<div class="section-header">
<div><div class="section-title" style="color:var(--error)">${I18n.t('loginResult.fail')}</div></div>
</div>
<div class="section-body"><p class="text-sm">${this.esc(err.message)}</p></div>`);
this.toast(err.message, 'error');
<div class="section-body"><p class="text-sm">${this.esc((errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message)}</p></div>`);
const translatedErr = (errKey && I18n.t(errKey) !== errKey) ? I18n.t(errKey) : err.message;
this.toast(translatedErr, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg> ' + I18n.t('action.batchImport');
Expand All @@ -2956,7 +2983,7 @@ <h3 data-i18n="login.title">控制台登录</h3>
<td>${this.esc(h.email)}</td>
<td>
<span class="badge ${ok ? 'active' : 'error'}">${ok ? I18n.t('batch.success') : I18n.t('batch.fail')}</span>
${!ok ? `<div class="text-xs" style="color:var(--error);margin-top:3px;max-width:240px;overflow:hidden;text-overflow:ellipsis">${this.esc((h.status||'').replace('error: ',''))}</div>` : ''}
${!ok ? (() => { const errCode = (h.status||'').replace('error: ',''); const errKey = `error.${errCode}`; const errMsg = I18n.t(errKey) !== errKey ? I18n.t(errKey) : errCode; return `<div class="text-xs" style="color:var(--error);margin-top:3px;max-width:240px;overflow:hidden;text-overflow:ellipsis" title="${this.esc(errCode)}">${this.esc(errMsg)}</div>`; })() : ''}
</td>
<td class="text-sm">${this.esc(h.proxy || '-')}</td>
<td><button class="btn btn-ghost btn-xs" onclick="App.removeLoginHistory(${i})">${I18n.t('action.delete')}</button></td>
Expand Down
6 changes: 3 additions & 3 deletions src/net-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,17 @@ export function isPrivateIp(address) {

export async function resolvePublicAddresses(hostname, lookupFn = dnsLookup) {
const host = String(hostname || '').replace(/^\[|\]$/g, '');
if (!host || host.toLowerCase() === 'localhost') throw new Error('ERR_PRIVATE_HOST');
if (!host || host.toLowerCase() === 'localhost') throw new Error('ERR_PROXY_PRIVATE_HOST');
if (net.isIP(host)) {
if (isPrivateIp(host)) throw new Error('ERR_PRIVATE_IP');
if (isPrivateIp(host)) throw new Error('ERR_PROXY_PRIVATE_IP');
return [{ address: host, family: net.isIP(host) }];
}
const result = await new Promise((resolve, reject) => {
lookupFn(host, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs));
});
const addrs = Array.isArray(result) ? result : [result];
for (const a of addrs) {
if (isPrivateIp(a.address)) throw new Error('ERR_PRIVATE_IP');
if (isPrivateIp(a.address)) throw new Error('ERR_PROXY_PRIVATE_IP');
}
return addrs;
}
Expand Down
3 changes: 1 addition & 2 deletions test/ssrf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,11 @@ describe('SSRF private address detection', () => {

it('rejects hostnames after DNS resolution to private IPs', async () => {
const lookup = (host, opts, cb) => cb(null, [{ address: '127.0.0.1', family: 4 }]);
await assert.rejects(() => resolvePublicAddresses('evil.example', lookup), /ERR_PRIVATE_IP/);
await assert.rejects(() => resolvePublicAddresses('evil.example', lookup), /ERR_PROXY_PRIVATE_IP/);
});

it('rejects oversized generic data URLs', () => {
const tooLarge = 'data:application/pdf;base64,' + 'A'.repeat(Math.ceil(5 * 1024 * 1024 * 4 / 3) + 200);
assert.throws(() => parseGenericDataUrl(tooLarge), /Data URL exceeds/);
});
});

Loading