feat: 增强 OAuth 回调地址验证,确保仅接受有效的 localhost 回调
This commit is contained in:
@@ -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
@@ -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
@@ -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。');
|
||||
|
||||
Reference in New Issue
Block a user