fix: allow reverse proxy login

This commit is contained in:
Qiufeng
2026-09-03 15:27:10 +08:00
parent 36c2ed1361
commit 4fda7b0846
9 changed files with 6 additions and 49 deletions
-3
View File
@@ -3,9 +3,6 @@ TALLYNOTE_PORT=3000
TALLYNOTE_DATA_DIR=./data TALLYNOTE_DATA_DIR=./data
TALLYNOTE_TIMEZONE=Asia/Shanghai TALLYNOTE_TIMEZONE=Asia/Shanghai
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000 TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
# Optional additional browser Origins for an explicit reverse-proxy alias.
# Keep the primary public origin above and list only trusted HTTPS origins.
# TALLYNOTE_ALLOWED_ORIGINS=https://tally.example.com,https://tally.internal.example
TALLYNOTE_TRUST_PROXY=false TALLYNOTE_TRUST_PROXY=false
TALLYNOTE_COOKIE_SECURE=false TALLYNOTE_COOKIE_SECURE=false
# Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct # Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct
+1 -1
View File
@@ -140,7 +140,7 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra
卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。 卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。登录和所有写入请求会校验浏览器 `Origin`;反代必须原样转发 `Origin`,且访问地址必须与 `TALLYNOTE_PUBLIC_ORIGIN` 完全一致。若确实需要多个受信任域名,可用 `TALLYNOTE_ALLOWED_ORIGINS=https://a.example.com,https://b.example.com` 显式列出(只写 Origin,不含路径),不要把它设为任意来源。 公网反代推荐使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。反代只需把域名转发到 TallyNote 端口并保留 `Host`、`X-Forwarded-Proto`;应用不会因为代理缺少或改写浏览器 `Origin` 而拦截登录。已认证写请求仍使用会话 Cookie 与 CSRF 令牌保护。
### 构建发布包 ### 构建发布包
-1
View File
@@ -1460,7 +1460,6 @@ main() {
elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN" set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
fi fi
if [[ -n "${TALLYNOTE_ALLOWED_ORIGINS+x}" ]]; then set_env_key TALLYNOTE_ALLOWED_ORIGINS "$TALLYNOTE_ALLOWED_ORIGINS"; fi
if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi
ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX" ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX"
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR" ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tallynote", "name": "tallynote",
"version": "1.1.21", "version": "1.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.0.6",
-13
View File
@@ -648,19 +648,6 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return payload; return payload;
}); });
app.addHook("onRequest", async (request) => {
if (!unsafeMethods.has(request.method) || !request.url.startsWith("/api/")) return;
const origin = request.headers.origin;
const allowed = new Set(config.allowedOrigins);
if (!config.isProduction) {
allowed.add("http://127.0.0.1:5173");
allowed.add("http://localhost:5173");
}
if (typeof origin !== "string" || !allowed.has(origin)) {
throw new AppError(403, "ORIGIN_FORBIDDEN", "请求来源不受信任");
}
});
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error)); if (error instanceof AppError) return reply.code(error.statusCode).send(errorPayload(request, error));
if (error instanceof ZodError) { if (error instanceof ZodError) {
-17
View File
@@ -43,21 +43,6 @@ function csvEnv(name: string): string[] {
.filter(Boolean); .filter(Boolean);
} }
function originListEnv(name: string, primary: string): string[] {
const values = [primary, ...csvEnv(name)];
const origins = new Set<string>();
for (const value of values) {
try {
const parsed = new URL(value);
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/" && parsed.pathname !== "" || parsed.search || parsed.hash) throw new Error();
origins.add(parsed.origin);
} catch {
throw new Error(`${name} 必须是逗号分隔的 HTTP(S) Origin(不含路径)`);
}
}
return [...origins];
}
function updatePublicKeyEnv(): string | undefined { function updatePublicKeyEnv(): string | undefined {
const inline = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY?.trim(); const inline = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY?.trim();
const file = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY_FILE?.trim(); const file = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY_FILE?.trim();
@@ -113,7 +98,6 @@ export function loadConfig() {
throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名"); throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名");
} }
const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost); const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost);
const allowedOrigins = originListEnv("TALLYNOTE_ALLOWED_ORIGINS", parsedOrigin.origin);
const appVersion = (() => { const appVersion = (() => {
try { try {
const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")) as { version?: unknown }; const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")) as { version?: unknown };
@@ -143,7 +127,6 @@ export function loadConfig() {
host, host,
port, port,
publicOrigin: parsedOrigin.origin, publicOrigin: parsedOrigin.origin,
allowedOrigins,
timezone, timezone,
trustProxy: trustProxyEnv(), trustProxy: trustProxyEnv(),
cookieSecure, cookieSecure,
-1
View File
@@ -3,7 +3,6 @@ TALLYNOTE_PORT=3000
TALLYNOTE_DATA_DIR=/var/lib/tallynote TALLYNOTE_DATA_DIR=/var/lib/tallynote
TALLYNOTE_INSTALL_PREFIX=/opt/tallynote TALLYNOTE_INSTALL_PREFIX=/opt/tallynote
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000 TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
TALLYNOTE_ALLOWED_ORIGINS=http://127.0.0.1:3000
TALLYNOTE_COOKIE_SECURE=false TALLYNOTE_COOKIE_SECURE=false
TALLYNOTE_ALLOW_INSECURE_HTTP=false TALLYNOTE_ALLOW_INSECURE_HTTP=false
TALLYNOTE_TIMEZONE=Asia/Shanghai TALLYNOTE_TIMEZONE=Asia/Shanghai
+3 -3
View File
@@ -129,10 +129,10 @@ describe("TallyNote API", () => {
} }
}); });
it("拒绝没有 Origin 的写请求", async () => { it("反向代理缺少 Origin 时仍允许登录请求进入认证流程", async () => {
const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } }); const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "x" } });
expect(response.statusCode).toBe(403); expect(response.statusCode).toBe(401);
expect(response.json().error.code).toBe("ORIGIN_FORBIDDEN"); expect(response.json().error.code).toBe("INVALID_CREDENTIALS");
}); });
it("将非法 JSON、伪造请求 ID 处理为结构化 400", async () => { it("将非法 JSON、伪造请求 ID 处理为结构化 400", async () => {
+1 -9
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { loadConfig, prepareDataDirectories } from "../server/config.js"; import { loadConfig, prepareDataDirectories } from "../server/config.js";
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_ALLOWED_ORIGINS", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"]; const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
afterEach(() => { for (const key of keys) delete process.env[key]; }); afterEach(() => { for (const key of keys) delete process.env[key]; });
@@ -48,14 +48,6 @@ describe("部署安全配置", () => {
expect(loadConfig().trustProxy).toBe(1); expect(loadConfig().trustProxy).toBe(1);
}); });
it("允许显式列出反向代理的多个可信 Origin", () => {
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://tally.example.test";
process.env.TALLYNOTE_COOKIE_SECURE = "true";
process.env.TALLYNOTE_ALLOWED_ORIGINS = "https://tally.example.test, https://tally.internal.test:8443";
expect(loadConfig().allowedOrigins).toEqual(["https://tally.example.test", "https://tally.internal.test:8443"]);
process.env.TALLYNOTE_ALLOWED_ORIGINS = "https://tally.example.test/app";
expect(() => loadConfig()).toThrow(/Origin/);
});
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => { it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";