fix: allow reverse proxy login
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 令牌保护。
|
||||||
|
|
||||||
### 构建发布包
|
### 构建发布包
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user