Bladeren bron

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

QLHazyCoder 4 maanden geleden
bovenliggende
commit
bac7113c0c
3 gewijzigde bestanden met toevoegingen van 86 en 30 verwijderingen
  1. 17 0
      README.md
  2. 40 28
      background.js
  3. 29 2
      content/vps-panel.js

+ 17 - 0
README.md

@@ -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
background.js

@@ -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
content/vps-panel.js

@@ -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('步骤 9:payload 中没有 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。');