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