Compare commits

..
2 Commits
Author SHA1 Message Date
Qiufeng c518890fc3 fix: keep opted-in HTTP assets on HTTP
TallyNote release / linux-x64 (push) Successful in 6m7s
2026-09-02 15:11:48 +08:00
Qiufeng 1925676fc9 fix: allow netlink for wildcard listener startup
TallyNote release / linux-x64 (push) Successful in 6m4s
2026-09-02 14:06:49 +08:00
6 changed files with 61 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tallynote", "name": "tallynote",
"version": "1.1.9", "version": "1.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.0.6",
+2
View File
@@ -2,6 +2,8 @@
set -Eeuo pipefail set -Eeuo pipefail
root=$(cd "$(dirname "$0")/.." && pwd) root=$(cd "$(dirname "$0")/.." && pwd)
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh" bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote.service"
grep -Eq '^RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK$' "$root/systemd/tallynote-update.service"
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases) output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
grep -q 'dry-run' <<<"$output" grep -q 'dry-run' <<<"$output"
grep -q '\[阶段\] 检查运行环境' <<<"$output" grep -q '\[阶段\] 检查运行环境' <<<"$output"
+10 -3
View File
@@ -590,6 +590,13 @@ function conflict(database: DatabaseContext, id: string): never {
} }
export async function buildApp(database: DatabaseContext, config: AppConfig) { export async function buildApp(database: DatabaseContext, config: AppConfig) {
// Helmet's defaults include `upgrade-insecure-requests`, HSTS, COOP and
// Origin-Agent-Cluster. Those headers are appropriate for HTTPS, but an
// explicitly opted-in HTTP deployment must remain HTTP all the way through
// the asset graph; otherwise browsers upgrade `/assets/*` to HTTPS and the
// plain HTTP listener appears as a blank page. Keep the transport-sensitive
// headers protocol-aware while retaining the other hardening headers.
const secureOrigin = config.publicOrigin.startsWith("https:");
const app = Fastify({ const app = Fastify({
logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false, logger: config.isProduction ? { level: "info", redact: ["req.headers.cookie", "req.headers.x-csrf-token", "password", "temporaryPassword"] } : false,
// Fastify's runtime accepts a numeric hop count, while its v5 typings do // Fastify's runtime accepts a numeric hop count, while its v5 typings do
@@ -604,10 +611,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
await app.register(cookie); await app.register(cookie);
await app.register(helmet, { await app.register(helmet, {
...(config.isLocalOrigin ? { hsts: false } : {}), ...(!secureOrigin || config.isLocalOrigin ? { hsts: false } : {}),
frameguard: { action: "deny" }, frameguard: { action: "deny" },
referrerPolicy: { policy: "no-referrer" }, referrerPolicy: { policy: "no-referrer" },
crossOriginOpenerPolicy: { policy: "same-origin" }, ...(secureOrigin ? { crossOriginOpenerPolicy: { policy: "same-origin" }, originAgentCluster: true } : { crossOriginOpenerPolicy: false, originAgentCluster: false }),
crossOriginResourcePolicy: { policy: "same-origin" }, crossOriginResourcePolicy: { policy: "same-origin" },
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
@@ -618,7 +625,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
"frame-ancestors": ["'none'"], "frame-ancestors": ["'none'"],
"base-uri": ["'none'"], "base-uri": ["'none'"],
"form-action": ["'self'"], "form-action": ["'self'"],
...(config.isLocalOrigin ? { "upgrade-insecure-requests": null } : {}), ...(!secureOrigin ? { "upgrade-insecure-requests": null } : {}),
}, },
}, },
}); });
+3 -1
View File
@@ -15,7 +15,9 @@ Environment=PATH=/usr/sbin:/usr/bin:/sbin:/bin
NoNewPrivileges=true NoNewPrivileges=true
CapabilityBoundingSet= CapabilityBoundingSet=
AmbientCapabilities= AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 # Keep the updater compatible with the same Node/libuv interface discovery
# path while retaining an explicit socket-family allowlist.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
PrivateTmp=true PrivateTmp=true
PrivateDevices=true PrivateDevices=true
ProtectHome=true ProtectHome=true
+3 -1
View File
@@ -20,7 +20,9 @@ ProtectSystem=strict
InaccessiblePaths=/opt/tallynote/.update-work InaccessiblePaths=/opt/tallynote/.update-work
ProtectHome=true ProtectHome=true
PrivateDevices=true PrivateDevices=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 # Fastify logs the addresses of wildcard listeners. Node's libuv uses the
# Linux netlink family while enumerating interfaces for that log message.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true ProtectKernelModules=true
ProtectKernelLogs=true ProtectKernelLogs=true
+42
View File
@@ -87,6 +87,48 @@ describe("TallyNote API", () => {
expect(missing.json().error.requestId).toBeTruthy(); expect(missing.json().error.requestId).toBeTruthy();
}); });
it("显式允许的公网 HTTP 不会把静态资源升级到 HTTPS", async () => {
const publicHttpConfig = {
...config,
publicOrigin: "http://192.0.2.10:3999",
isLocalOrigin: false,
allowInsecureHttp: true,
cookieSecure: false,
};
const publicHttpApp = await buildApp(database, publicHttpConfig);
try {
const response = await publicHttpApp.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.headers["content-security-policy"]).not.toContain("upgrade-insecure-requests");
expect(response.headers["strict-transport-security"]).toBeUndefined();
expect(response.headers["cross-origin-opener-policy"]).toBeUndefined();
expect(response.headers["origin-agent-cluster"]).toBeUndefined();
} finally {
await publicHttpApp.close();
}
});
it("HTTPS 仍保留传输安全响应头", async () => {
const secureConfig = {
...config,
publicOrigin: "https://example.test:3999",
isLocalOrigin: false,
allowInsecureHttp: false,
cookieSecure: true,
};
const secureApp = await buildApp(database, secureConfig);
try {
const response = await secureApp.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.headers["content-security-policy"]).toContain("upgrade-insecure-requests");
expect(response.headers["strict-transport-security"]).toContain("max-age=");
expect(response.headers["cross-origin-opener-policy"]).toBe("same-origin");
expect(response.headers["origin-agent-cluster"]).toBe("?1");
} finally {
await secureApp.close();
}
});
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(403);