fix: avoid loopback api base on public web origin

This commit is contained in:
chengkai3
2026-04-12 23:09:52 +08:00
parent c2505eb70c
commit 3fbd603eae
6 changed files with 72 additions and 8 deletions
+5
View File
@@ -55,3 +55,8 @@
- 标题:`Space Grotesk`
- 正文:`Manrope`
- 等宽:`JetBrains Mono`
## 前端联调口径(2026-04-12
- `NEXT_PUBLIC_API_BASE_URL` 若误配为 loopback`127.0.0.1/localhost`),前端运行时会在浏览器端自动改写为“当前页面主机 + 同端口(默认 8000)”,避免公网页面触发 PNAPrivate Network Access)阻断。
- 认证请求与 WebSocket 连接均统一复用该运行时 API 基址解析逻辑。
+17
View File
@@ -214,3 +214,20 @@
- 注册显示 `注册`
- 验证:
- 复跑 `docker compose build web`,构建成功,路由静态生成完成,最终输出 `fquiz-web Built`
## 追加修复(浏览器 PNA 阻断导致登录失败)
- 触发问题:
- 浏览器报错:从 `http://223.109.142.84:3000` 访问 `http://127.0.0.1:8000` 被拦截,提示
`The request client is not a secure context and the resource is in more-private address space loopback`
- 根因:
- 前端构建时注入 `NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000`,线上浏览器会把 `127.0.0.1` 解释为“访问者本机回环地址”,触发 PNA 安全策略阻断。
- 处理:
- 新增前端 API 基址运行时解析(`web/src/lib/api.ts`):
- 浏览器端若发现配置为 loopback(`127.0.0.1/localhost`),且当前页面不在 loopback 主机,则自动改写为当前页面主机 + 原端口(默认 `8000`)。
- 认证请求统一走运行时 API 基址(`web/src/components/auth-provider.tsx`)。
- WS 连接统一走运行时 API 基址(`web/src/components/ws-provider.tsx`)。
- 首页 `Ping` 与 API Base 展示改为运行时解析值(`web/src/app/page.tsx`)。
- 验证:
- `cd web && npx eslint src/lib/api.ts src/components/auth-provider.tsx src/components/ws-provider.tsx src/app/page.tsx` 通过。
- `cd web && npx tsc --noEmit` 通过。
+8 -3
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { FormEvent, useEffect, useState } from "react";
import { useAuth } from "@/components/auth-provider";
import { API_BASE_URL, readApiError } from "@/lib/api";
import { API_BASE_URL, getApiBaseUrl, readApiError } from "@/lib/api";
type Mode = "login" | "register";
type PingResponse = { message: string };
@@ -27,6 +27,7 @@ export default function Home() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [pingResult, setPingResult] = useState<PingResponse | null>(null);
const [resolvedApiBaseUrl, setResolvedApiBaseUrl] = useState(API_BASE_URL);
useEffect(() => {
try {
@@ -47,6 +48,10 @@ export default function Home() {
}
}, []);
useEffect(() => {
setResolvedApiBaseUrl(getApiBaseUrl());
}, []);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError("");
@@ -78,7 +83,7 @@ export default function Home() {
const handlePing = async () => {
setError("");
const response = await fetch(`${API_BASE_URL}/api/v1/ping`, {
const response = await fetch(`${getApiBaseUrl()}/api/v1/ping`, {
method: "GET",
credentials: "include",
});
@@ -106,7 +111,7 @@ export default function Home() {
<section className="surface-card">
<p className="text-sm text-muted">API Base URL</p>
<p className="mt-1 font-mono text-sm">{API_BASE_URL}</p>
<p className="mt-1 font-mono text-sm">{resolvedApiBaseUrl}</p>
</section>
{user ? (
+2 -2
View File
@@ -10,7 +10,7 @@ import {
useState,
} from "react";
import { API_BASE_URL, readApiError } from "@/lib/api";
import { getApiBaseUrl, readApiError } from "@/lib/api";
import type { AuthTokenResponse, UserPublic } from "@/types/auth";
type AuthContextValue = {
@@ -36,7 +36,7 @@ function withApiPath(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
return `${API_BASE_URL}${path}`;
return `${getApiBaseUrl()}${path}`;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
+2 -1
View File
@@ -12,6 +12,7 @@ import {
} from "react";
import { useAuth } from "@/components/auth-provider";
import { getApiBaseUrl } from "@/lib/api";
import type { WsEventEnvelope, WsServerMessage, WsTicketResponse } from "@/types/ws";
type TopicHandler = (event: WsEventEnvelope) => void;
@@ -25,7 +26,7 @@ type WSContextValue = {
const WSContext = createContext<WSContextValue | undefined>(undefined);
function toWebSocketUrl(path: string): string {
const base = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
const base = getApiBaseUrl();
const url = new URL(path, base);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
+38 -2
View File
@@ -1,5 +1,41 @@
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function isLoopbackHost(hostname: string): boolean {
return LOOPBACK_HOSTS.has(hostname.toLowerCase());
}
export function getApiBaseUrl(): string {
const configured =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ?? "http://127.0.0.1:8000";
if (typeof window === "undefined") {
return trimTrailingSlash(configured);
}
let parsed: URL;
try {
parsed = new URL(configured);
} catch {
return trimTrailingSlash(configured);
}
const browserHost = window.location.hostname;
const shouldRewriteLoopback =
isLoopbackHost(parsed.hostname) && !isLoopbackHost(browserHost);
if (!shouldRewriteLoopback) {
return trimTrailingSlash(configured);
}
const port = parsed.port || "8000";
const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
return `${parsed.protocol}//${browserHost}:${port}${pathname}`;
}
export const API_BASE_URL = getApiBaseUrl();
export async function readApiError(response: Response): Promise<string> {
try {