refactor: update MITM bypass logic and enhance combo name validation
This commit is contained in:
@@ -8,9 +8,14 @@ const proxyDispatchers = new Map();
|
|||||||
|
|
||||||
// DNS cache — use Map to avoid prototype pollution via malformed hostnames
|
// DNS cache — use Map to avoid prototype pollution via malformed hostnames
|
||||||
const DNS_CACHE = new Map();
|
const DNS_CACHE = new Map();
|
||||||
const MITM_BYPASS_HOSTS = ["cloudcode-pa.googleapis.com", "daily-cloudcode-pa.googleapis.com", "googleapis.com"];
|
const MITM_BYPASS_HOSTS = [
|
||||||
const MITM_BYPASS_HEADER = "x-request-source";
|
"cloudcode-pa.googleapis.com",
|
||||||
const MITM_BYPASS_VALUE = "local";
|
"daily-cloudcode-pa.googleapis.com",
|
||||||
|
"api.individual.githubcopilot.com",
|
||||||
|
"q.us-east-1.amazonaws.com",
|
||||||
|
"codewhisperer.us-east-1.amazonaws.com",
|
||||||
|
"api2.cursor.sh",
|
||||||
|
];
|
||||||
const GOOGLE_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"];
|
const GOOGLE_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"];
|
||||||
const HTTPS_PORT = 443;
|
const HTTPS_PORT = 443;
|
||||||
const HTTP_SUCCESS_MIN = 200;
|
const HTTP_SUCCESS_MIN = 200;
|
||||||
@@ -46,23 +51,7 @@ async function resolveRealIP(hostname) {
|
|||||||
/**
|
/**
|
||||||
* Check if request should bypass MITM DNS redirect
|
* Check if request should bypass MITM DNS redirect
|
||||||
*/
|
*/
|
||||||
function shouldBypassMitmDns(url, options) {
|
function shouldBypassMitmDns(url) {
|
||||||
if (!options?.headers) return false;
|
|
||||||
|
|
||||||
const headers = options.headers;
|
|
||||||
const hasLocalMarker = headers[MITM_BYPASS_HEADER] === MITM_BYPASS_VALUE ||
|
|
||||||
headers[MITM_BYPASS_HEADER.charAt(0).toUpperCase() + MITM_BYPASS_HEADER.slice(1)] === MITM_BYPASS_VALUE;
|
|
||||||
|
|
||||||
if (!hasLocalMarker) {
|
|
||||||
try {
|
|
||||||
const hostname = new URL(url).hostname;
|
|
||||||
if (MITM_BYPASS_HOSTS.some(host => hostname.includes(host))) {
|
|
||||||
console.warn(`[ProxyFetch] MITM bypass NOT triggered for ${hostname} - missing header`);
|
|
||||||
}
|
|
||||||
} catch { /* invalid URL — skip debug log */ }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hostname = new URL(url).hostname;
|
const hostname = new URL(url).hostname;
|
||||||
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
||||||
@@ -209,8 +198,25 @@ async function createBypassRequest(parsedUrl, realIP, options) {
|
|||||||
export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
||||||
const targetUrl = typeof url === "string" ? url : url.toString();
|
const targetUrl = typeof url === "string" ? url : url.toString();
|
||||||
|
|
||||||
// MITM DNS bypass: resolve real IP for googleapis.com when x-request-source: local
|
const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions);
|
||||||
if (shouldBypassMitmDns(targetUrl, options)) {
|
const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl));
|
||||||
|
const proxyUrl = connectionProxyUrl || envProxyUrl;
|
||||||
|
|
||||||
|
// MITM DNS bypass: for known MITM-intercepted hosts, resolve real IP to avoid DNS spoof
|
||||||
|
if (shouldBypassMitmDns(targetUrl)) {
|
||||||
|
if (proxyUrl) {
|
||||||
|
// Proxy resolves DNS externally (not affected by /etc/hosts) — use proxy directly
|
||||||
|
try {
|
||||||
|
const dispatcher = await getDispatcher(proxyUrl);
|
||||||
|
return await originalFetch(url, { ...options, dispatcher });
|
||||||
|
} catch (proxyError) {
|
||||||
|
if (proxyOptions?.strictProxy === true) {
|
||||||
|
throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`);
|
||||||
|
}
|
||||||
|
console.warn(`[ProxyFetch] Proxy failed, falling back to direct bypass: ${proxyError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No proxy — manually resolve real IP to bypass DNS spoof
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(targetUrl);
|
const parsedUrl = new URL(targetUrl);
|
||||||
const realIP = await resolveRealIP(parsedUrl.hostname);
|
const realIP = await resolveRealIP(parsedUrl.hostname);
|
||||||
@@ -220,10 +226,6 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions);
|
|
||||||
const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl));
|
|
||||||
const proxyUrl = connectionProxyUrl || envProxyUrl;
|
|
||||||
|
|
||||||
if (proxyUrl) {
|
if (proxyUrl) {
|
||||||
try {
|
try {
|
||||||
const dispatcher = await getDispatcher(proxyUrl);
|
const dispatcher = await getDispatcher(proxyUrl);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
|||||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
|
|
||||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
|
||||||
|
|
||||||
export default function CombosPage() {
|
export default function CombosPage() {
|
||||||
const [combos, setCombos] = useState([]);
|
const [combos, setCombos] = useState([]);
|
||||||
@@ -329,7 +329,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!VALID_NAME_REGEX.test(value)) {
|
if (!VALID_NAME_REGEX.test(value)) {
|
||||||
setNameError("Only letters, numbers, - and _ allowed");
|
setNameError("Only letters, numbers, -, _ and . allowed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setNameError("");
|
setNameError("");
|
||||||
@@ -394,7 +394,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
|||||||
error={nameError}
|
error={nameError}
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-text-muted mt-0.5">
|
<p className="text-[10px] text-text-muted mt-0.5">
|
||||||
Only letters, numbers, - and _ allowed
|
Only letters, numbers, -, _ and . allowed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -290,40 +290,28 @@ export default function ProviderDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwapPriority = async (conn1, conn2) => {
|
const handleSwapPriority = async (index1, index2) => {
|
||||||
if (!conn1 || !conn2) return;
|
// Optimistic update state
|
||||||
|
const newConnections = [...connections];
|
||||||
|
[newConnections[index1], newConnections[index2]] = [newConnections[index2], newConnections[index1]];
|
||||||
|
setConnections(newConnections);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// If they have the same priority, we need to ensure the one moving up
|
|
||||||
// gets a lower value than the one moving down.
|
|
||||||
// We use a small offset which the backend re-indexing will fix.
|
|
||||||
let p1 = conn2.priority;
|
|
||||||
let p2 = conn1.priority;
|
|
||||||
|
|
||||||
if (p1 === p2) {
|
|
||||||
// If moving conn1 "up" (index decreases)
|
|
||||||
const isConn1MovingUp = connections.indexOf(conn1) > connections.indexOf(conn2);
|
|
||||||
if (isConn1MovingUp) {
|
|
||||||
p1 = conn2.priority - 0.5;
|
|
||||||
} else {
|
|
||||||
p1 = conn2.priority + 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetch(`/api/providers/${conn1.id}`, {
|
fetch(`/api/providers/${newConnections[index1].id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ priority: p1 }),
|
body: JSON.stringify({ priority: index1 }),
|
||||||
}),
|
}),
|
||||||
fetch(`/api/providers/${conn2.id}`, {
|
fetch(`/api/providers/${newConnections[index2].id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ priority: p2 }),
|
body: JSON.stringify({ priority: index2 }),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
await fetchConnections();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error swapping priority:", error);
|
console.log("Error swapping priority:", error);
|
||||||
|
await fetchConnections();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -421,7 +409,6 @@ export default function ProviderDetailPage() {
|
|||||||
const connectionsList = (
|
const connectionsList = (
|
||||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||||
{connections
|
{connections
|
||||||
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
|
|
||||||
.map((conn, index) => (
|
.map((conn, index) => (
|
||||||
<div key={conn.id} className="flex items-stretch">
|
<div key={conn.id} className="flex items-stretch">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -431,8 +418,8 @@ export default function ProviderDetailPage() {
|
|||||||
isOAuth={isOAuth}
|
isOAuth={isOAuth}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
isLast={index === connections.length - 1}
|
isLast={index === connections.length - 1}
|
||||||
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
|
onMoveUp={() => handleSwapPriority(index, index - 1)}
|
||||||
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
|
onMoveDown={() => handleSwapPriority(index, index + 1)}
|
||||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||||
onUpdateProxy={async (proxyPoolId) => {
|
onUpdateProxy={async (proxyPoolId) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb";
|
import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb";
|
||||||
|
|
||||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
|
||||||
|
|
||||||
// GET /api/combos/[id] - Get combo by ID
|
// GET /api/combos/[id] - Get combo by ID
|
||||||
export async function GET(request, { params }) {
|
export async function GET(request, { params }) {
|
||||||
@@ -30,7 +30,7 @@ export async function PUT(request, { params }) {
|
|||||||
// Validate name format if provided
|
// Validate name format if provided
|
||||||
if (body.name) {
|
if (body.name) {
|
||||||
if (!VALID_NAME_REGEX.test(body.name)) {
|
if (!VALID_NAME_REGEX.test(body.name)) {
|
||||||
return NextResponse.json({ error: "Name can only contain letters, numbers, - and _" }, { status: 400 });
|
return NextResponse.json({ error: "Name can only contain letters, numbers, -, _ and ." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if name already exists (exclude current combo)
|
// Check if name already exists (exclude current combo)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getCombos, createCombo, getComboByName } from "@/lib/localDb";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
|
||||||
|
|
||||||
// GET /api/combos - Get all combos
|
// GET /api/combos - Get all combos
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -29,7 +29,7 @@ export async function POST(request) {
|
|||||||
|
|
||||||
// Validate name format
|
// Validate name format
|
||||||
if (!VALID_NAME_REGEX.test(name)) {
|
if (!VALID_NAME_REGEX.test(name)) {
|
||||||
return NextResponse.json({ error: "Name can only contain letters, numbers, - and _" }, { status: 400 });
|
return NextResponse.json({ error: "Name can only contain letters, numbers, -, _ and ." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if name already exists
|
// Check if name already exists
|
||||||
|
|||||||
@@ -63,14 +63,17 @@ try {
|
|||||||
// ── Helpers ───────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const cachedTargetIPs = {};
|
const cachedTargetIPs = {};
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
async function resolveTargetIP(hostname) {
|
async function resolveTargetIP(hostname) {
|
||||||
if (cachedTargetIPs[hostname]) return cachedTargetIPs[hostname];
|
const cached = cachedTargetIPs[hostname];
|
||||||
|
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.ip;
|
||||||
const resolver = new dns.Resolver();
|
const resolver = new dns.Resolver();
|
||||||
resolver.setServers(["8.8.8.8"]);
|
resolver.setServers(["8.8.8.8"]);
|
||||||
const resolve4 = promisify(resolver.resolve4.bind(resolver));
|
const resolve4 = promisify(resolver.resolve4.bind(resolver));
|
||||||
const addresses = await resolve4(hostname);
|
const addresses = await resolve4(hostname);
|
||||||
cachedTargetIPs[hostname] = addresses[0];
|
cachedTargetIPs[hostname] = { ip: addresses[0], ts: Date.now() };
|
||||||
return cachedTargetIPs[hostname];
|
return cachedTargetIPs[hostname].ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectBodyRaw(req) {
|
function collectBodyRaw(req) {
|
||||||
|
|||||||
Reference in New Issue
Block a user