feat: 增强 OAuth 回调地址验证,确保仅接受有效的 localhost 回调

This commit is contained in:
QLHazyCoder
2026-04-11 01:31:43 +08:00
parent b6a850062b
commit bac7113c0c
3 changed files with 86 additions and 30 deletions
+17
View File
@@ -242,6 +242,12 @@ Step 3 使用的注册邮箱。
### Step 8: Manual OAuth Confirm
严格回调捕获规则:
- 步骤 8 现在只接受 `http(s)://localhost:<port>/auth/callback?code=...&state=...`
- 监听范围只限于当前 OAuth 认证标签页的主 frame 跳转
- 普通 `localhost` 页面,包括本地部署的 CPA 面板,不会再被误判为回调地址
虽然按钮名称还是 `Manual OAuth Confirm`,但当前代码已经做了自动尝试:
- 在授权页定位“继续”按钮
@@ -259,6 +265,11 @@ Step 3 使用的注册邮箱。
### Step 9: CPA Verify
校验规则:
- 步骤 9 会拒绝任何不是真实 `/auth/callback`,或缺少 `code` / `state``localhostUrl`
- 成功后的清理只会针对 `/auth` 这一类真实回调标签页,不会再泛化清理任意 localhost 路径
回到 CPA 面板:
- 自动填写 localhost 回调地址
@@ -381,6 +392,12 @@ sidepanel/ 侧边栏 UI
### 5. Step 8 失败时重点检查
补充检查项:
- 确认回调路径仍然是 `/auth/callback`
- 确认回调 query 里仍然同时包含 `code``state`
- 如果 CPA 部署在 `localhost`,确认当前看到的页面是真实 OAuth 回调,而不是 CPA 面板自身页面
- OAuth 同意页 DOM 是否变化
- “继续”按钮是否变成了别的文案
- localhost 回调是否真的触发
+40 -28
View File
@@ -229,16 +229,23 @@ function is163MailHost(hostname = '') {
|| hostname === 'webmail.vip.163.com';
}
function buildLocalhostCleanupPrefix(rawUrl) {
function isLocalhostOAuthCallbackUrl(rawUrl) {
const parsed = parseUrlSafely(rawUrl);
if (!parsed || parsed.hostname !== 'localhost') return '';
if (!parsed) return false;
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
if (parsed.hostname !== 'localhost') return false;
if (parsed.pathname !== '/auth/callback') return false;
const segments = parsed.pathname.split('/').filter(Boolean);
if (!segments.length) {
return parsed.origin;
}
const code = (parsed.searchParams.get('code') || '').trim();
const state = (parsed.searchParams.get('state') || '').trim();
return Boolean(code && state);
}
return `${parsed.origin}/${segments[0]}`;
function buildLocalhostCleanupPrefix(rawUrl) {
if (!isLocalhostOAuthCallbackUrl(rawUrl)) return '';
const parsed = parseUrlSafely(rawUrl);
return parsed ? `${parsed.origin}/auth` : '';
}
function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) {
@@ -261,7 +268,9 @@ function matchesSourceUrlFamily(source, candidateUrl, referenceUrl) {
&& candidate.origin === reference.origin
&& candidate.pathname.startsWith('/m/');
case 'vps-panel':
return Boolean(reference) && candidate.origin === reference.origin;
return Boolean(reference)
&& candidate.origin === reference.origin
&& candidate.pathname === reference.pathname;
default:
return false;
}
@@ -1126,6 +1135,9 @@ async function handleStepData(step, payload) {
break;
case 8:
if (payload.localhostUrl) {
if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) {
throw new Error('步骤 8 返回了无效的 localhost OAuth 回调地址。');
}
await setState({ localhostUrl: payload.localhostUrl });
broadcastDataUpdate({ localhostUrl: payload.localhostUrl });
}
@@ -2252,7 +2264,7 @@ async function executeStep7(state) {
}
// ============================================================
// Step 8: Complete OAuth (auto click + localhost listener)
// Step 8: 完成 OAuth(自动点击 + localhost 回调监听)
// ============================================================
let webNavListener = null;
@@ -2264,13 +2276,10 @@ async function executeStep8(state) {
await addLog('步骤 8:正在监听 localhost 回调地址...');
// Register webNavigation listener (scoped to this step)
// 只在当前步骤内注册 webNavigation 监听
return new Promise((resolve, reject) => {
let resolved = false;
let resolveCaptureWait = null;
const captureWait = new Promise((resolveCapture) => {
resolveCaptureWait = resolveCapture;
});
let signupTabId = null;
const cleanupListener = () => {
if (webNavListener) {
@@ -2285,31 +2294,29 @@ async function executeStep8(state) {
}, 120000);
webNavListener = (details) => {
if (details.url.startsWith('http://localhost')) {
console.log(LOG_PREFIX, `Captured localhost redirect: ${details.url}`);
if (resolved || !signupTabId) return;
if (details.tabId !== signupTabId) return;
if (details.frameId !== 0) return;
if (isLocalhostOAuthCallbackUrl(details.url)) {
console.log(LOG_PREFIX, `已捕获 localhost OAuth 回调:${details.url}`);
resolved = true;
cleanupListener();
clearTimeout(timeout);
if (resolveCaptureWait) resolveCaptureWait(details.url);
setState({ localhostUrl: details.url }).then(() => {
completeStepFromBackground(8, { localhostUrl: details.url }).then(() => {
addLog(`步骤 8:已捕获 localhost 地址:${details.url}`, 'ok');
setStepStatus(8, 'completed');
notifyStepComplete(8, { localhostUrl: details.url });
broadcastDataUpdate({ localhostUrl: details.url });
resolve();
}).catch((err) => {
reject(err);
});
}
};
chrome.webNavigation.onBeforeNavigate.addListener(webNavListener);
// After step 7, the auth page shows a consent screen ("使用 ChatGPT 登录到 Codex")
// with a "继续" button. We locate the button in-page, then click it through
// the debugger Input API directly.
// 步骤 7 之后,认证页会进入 OAuth 同意页,出现“继续”按钮。
// 这里先在页面内定位按钮,再通过 debugger 输入事件发起点击。
(async () => {
try {
let signupTabId = await getTabId('signup-page');
signupTabId = await getTabId('signup-page');
if (signupTabId) {
await chrome.tabs.update(signupTabId, { active: true });
await addLog('步骤 8:已切回认证页,正在准备调试器点击...');
@@ -2318,6 +2325,8 @@ async function executeStep8(state) {
await addLog('步骤 8:已重新打开认证页,正在准备调试器点击...');
}
chrome.webNavigation.onBeforeNavigate.addListener(webNavListener);
const clickResult = await sendToContentScript('signup-page', {
type: 'STEP8_FIND_AND_CLICK',
source: 'background',
@@ -2342,10 +2351,13 @@ async function executeStep8(state) {
}
// ============================================================
// Step 9: VPS Verify (via vps-panel.js)
// Step 9: CPA 回调验证(通过 vps-panel.js
// ============================================================
async function executeStep9(state) {
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error('步骤 8 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 8。');
}
if (!state.localhostUrl) {
throw new Error('缺少 localhost 回调地址,请先完成步骤 8。');
}
+29 -2
View File
@@ -76,6 +76,27 @@ function getActionText(el) {
.trim();
}
function parseUrlSafely(rawUrl) {
if (!rawUrl) return null;
try {
return new URL(rawUrl);
} catch {
return null;
}
}
function isLocalhostOAuthCallbackUrl(rawUrl) {
const parsed = parseUrlSafely(rawUrl);
if (!parsed) return false;
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
if (parsed.hostname !== 'localhost') return false;
if (parsed.pathname !== '/auth/callback') return false;
const code = (parsed.searchParams.get('code') || '').trim();
const state = (parsed.searchParams.get('state') || '').trim();
return Boolean(code && state);
}
function getStatusBadgeElement() {
const selectors = [
'#root > div > div > div > main > div > div > div > div > div:nth-child(1) > div > div.OAuthPage-module__cardContent___1sXLA > div.status-badge',
@@ -290,18 +311,24 @@ async function step1_getOAuthLink(payload) {
}
// ============================================================
// Step 9: CPA Verify — paste localhost URL and submit
// 步骤 9:CPA 回调验证——填写 localhost 回调地址并提交
// ============================================================
async function step9_vpsVerify(payload) {
await ensureOAuthManagementPage(payload?.vpsPassword, 9);
// Get localhostUrl from payload (passed directly by background) or fallback to state
// 优先从 payload 读取 localhostUrl;没有时再回退到全局状态
let localhostUrl = payload?.localhostUrl;
if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) {
throw new Error('步骤 9 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 8。');
}
if (!localhostUrl) {
log('步骤 9payload 中没有 localhostUrl,正在从状态中读取...');
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
localhostUrl = state.localhostUrl;
if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) {
throw new Error('步骤 9 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 8。');
}
}
if (!localhostUrl) {
throw new Error('未找到 localhost 回调地址,请先完成步骤 8。');