Skip to content

Commit b485967

Browse files
committed
fix: strengthen local runtime safeguards
1 parent f7e4ef5 commit b485967

39 files changed

Lines changed: 372 additions & 1759 deletions

README-CN.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
- 部署文档:[高级部署 - 编译源码构建,速度慢配置要求高](https://docs.subboost.org/deploy/advanced)
4040
- 配置教程:[草履虫也能学会的 Clash 配置:UI 界面一键配置精确分流、链式代理](https://ryanvan.com/t/topic/59?u=ryan)
4141

42+
## 订阅链接安全
43+
44+
完整的订阅链接及其中的 token 属于“持有者即可使用”的访问凭证。任何拿到完整链接的人,都可能读取对应的生成配置并使用该订阅。请像保管密码一样保管它,不要把完整链接放进公开仓库、Issue、聊天记录、截图或日志;怀疑泄漏时,应立即删除并重新创建相应订阅,以更换 token。
45+
46+
通过该 token 发起的请求会按订阅所属用户计入访问和风控记录。这是预期的权限模型,因此订阅所有者有责任避免完整链接泄漏。
47+
4248
## 开发说明
4349

4450
开发者可以从源码启动本地开发环境:

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
- Deployment docs: [Advanced deployment - compiles from source, slower with higher requirements](https://docs.subboost.org/deploy/advanced)
4040
- Configuration guide: [Clash configuration simple enough for a paramecium: configure precise routing and chained proxies from the UI in one click](https://ryanvan.com/t/topic/59?u=ryan)
4141

42+
## Subscription Link Security
43+
44+
A complete subscription URL and its token are bearer credentials: anyone who obtains the full URL may read the generated configuration and use that subscription. Protect it like a password. Never place the complete URL in public repositories, issues, chat messages, screenshots, or logs. If exposure is suspected, delete and recreate the affected subscription to issue a new token.
45+
46+
Requests made with a token are attributed to the user who owns the subscription for access controls and abuse prevention. This is the intended authorization model, so subscription owners are responsible for keeping complete subscription URLs confidential.
47+
4248
## Development Notes
4349

4450
Developers can start a local development environment from source:

local/Dockerfile

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ FROM node:22-alpine AS deps
22
WORKDIR /package
33
RUN apk add --no-cache openssl
44

5+
COPY package.json package-lock.json ./
56
COPY packages/core packages/core
67
COPY packages/server-core packages/server-core
78
COPY packages/ui packages/ui
89
COPY packages/config packages/config
9-
COPY local/package*.json local/
10+
COPY local/package.json local/package.json
1011

11-
WORKDIR /package/local
12-
RUN npm install
12+
RUN npm ci
1313

1414
FROM node:22-alpine AS builder
1515
WORKDIR /package
@@ -24,8 +24,7 @@ ENV APP_BUILD_SHA=${APP_BUILD_SHA}
2424
ENV APP_VERSION=${APP_VERSION}
2525
ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN}
2626

27-
COPY --from=deps /package/local/node_modules local/node_modules
28-
RUN ln -s local/node_modules node_modules
27+
COPY --from=deps /package/node_modules node_modules
2928
COPY packages/core packages/core
3029
COPY packages/server-core packages/server-core
3130
COPY packages/ui packages/ui
@@ -35,6 +34,9 @@ COPY local local
3534
WORKDIR /package/local
3635
RUN npm run build
3736

37+
FROM deps AS production-deps
38+
RUN npm prune --omit=dev
39+
3840
FROM node:22-alpine AS runner
3941
WORKDIR /app
4042
ENV NODE_ENV=production
@@ -48,14 +50,14 @@ ENV APP_VERSION=${APP_VERSION}
4850
ENV APP_VERSION_TOKEN=${APP_VERSION_TOKEN}
4951
RUN apk add --no-cache openssl
5052

51-
COPY --from=builder /package/local/.next/standalone ./
52-
COPY --from=deps /package/local/node_modules local/node_modules
53-
COPY --from=builder /package/local/.next/static local/.next/static
54-
COPY --from=builder /package/local/public local/public
55-
COPY --from=builder /package/local/prisma.config.ts local/prisma.config.ts
56-
COPY --from=builder /package/local/prisma local/prisma
57-
RUN if [ ! -e node_modules ] && [ -d local/node_modules ]; then ln -s local/node_modules node_modules; fi
53+
COPY --chown=node:node --from=builder /package/local/.next/standalone ./
54+
COPY --chown=node:node --from=production-deps /package/node_modules ./node_modules
55+
COPY --chown=node:node --from=builder /package/local/.next/static local/.next/static
56+
COPY --chown=node:node --from=builder /package/local/public local/public
57+
COPY --chown=node:node --from=builder /package/local/prisma.config.ts local/prisma.config.ts
58+
COPY --chown=node:node --from=builder /package/local/prisma local/prisma
5859

5960
WORKDIR /app/local
61+
USER node
6062
EXPOSE 3000
61-
CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"]
63+
CMD ["sh", "-c", "../node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma && node server.js"]

local/app/api/auth/local-auth-routes.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit";
23

34
const mocks = vi.hoisted(() => ({
45
bcryptCompare: vi.fn(),
@@ -49,6 +50,7 @@ async function readJson(response: Response) {
4950
describe("local auth and health routes", () => {
5051
beforeEach(() => {
5152
vi.clearAllMocks();
53+
clearLocalRateLimitsForTests();
5254
});
5355

5456
it("logs in a valid local admin and sets the session cookie", async () => {

local/app/api/auth/login/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,37 @@ import { NextResponse } from "next/server";
33
import { apiError, getStringField, readJsonBody } from "@local/lib/http";
44
import { prisma } from "@local/lib/prisma";
55
import { sessionCookieOptions, signSession, SESSION_COOKIE } from "@local/lib/session";
6+
import {
7+
consumeLocalRateLimit,
8+
hashLocalRateLimitKey,
9+
localRateLimitResponse,
10+
resetLocalRateLimit,
11+
} from "@local/lib/rate-limit";
12+
13+
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
614

715
export async function POST(request: Request) {
16+
const globalLimit = consumeLocalRateLimit("auth-login-global", "all", {
17+
limit: 30,
18+
windowMs: LOGIN_WINDOW_MS,
19+
});
20+
if (!globalLimit.allowed) {
21+
return localRateLimitResponse("Too many login attempts. Try again later.", globalLimit.retryAfterSeconds);
22+
}
23+
824
const body = await readJsonBody(request);
925
if (!body) return apiError("Invalid JSON body.", "BAD_REQUEST", 400);
1026

1127
const username = getStringField(body, "username");
1228
const password = getStringField(body, "password");
29+
const usernameLimitKey = hashLocalRateLimitKey(username.toLowerCase() || "missing");
30+
const usernameLimit = consumeLocalRateLimit("auth-login-username", usernameLimitKey, {
31+
limit: 8,
32+
windowMs: LOGIN_WINDOW_MS,
33+
});
34+
if (!usernameLimit.allowed) {
35+
return localRateLimitResponse("Too many login attempts. Try again later.", usernameLimit.retryAfterSeconds);
36+
}
1337
const admin = username
1438
? await prisma.localAdmin.findUnique({ where: { username }, select: { id: true, username: true, passwordHash: true } })
1539
: null;
@@ -18,6 +42,8 @@ export async function POST(request: Request) {
1842
return apiError("Invalid username or password.", "UNAUTHORIZED", 401);
1943
}
2044

45+
resetLocalRateLimit("auth-login-username", usernameLimitKey);
46+
2147
await prisma.localAdmin.update({ where: { id: admin.id }, data: { lastLoginAt: new Date() } });
2248
const response = NextResponse.json({ success: true, user: { id: admin.id, username: admin.username } });
2349
response.cookies.set(SESSION_COOKIE, await signSession({ adminId: admin.id, username: admin.username }), sessionCookieOptions());

local/app/api/setup/admin/route.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
77
getStringField: vi.fn((body: Record<string, unknown>, key: string) => (typeof body[key] === "string" ? String(body[key]).trim() : "")),
88
count: vi.fn(),
99
create: vi.fn(),
10+
queryRaw: vi.fn(),
11+
transaction: vi.fn(),
1012
signSession: vi.fn(),
1113
sessionCookieOptions: vi.fn(),
1214
}));
@@ -19,6 +21,7 @@ vi.mock("@local/lib/http", () => ({
1921
}));
2022
vi.mock("@local/lib/prisma", () => ({
2123
prisma: {
24+
$transaction: mocks.transaction,
2225
localAdmin: {
2326
count: mocks.count,
2427
create: mocks.create,
@@ -32,6 +35,7 @@ vi.mock("@local/lib/session", () => ({
3235
}));
3336

3437
import { POST } from "./route";
38+
import { clearLocalRateLimitsForTests } from "@local/lib/rate-limit";
3539

3640
async function readJson(response: Response) {
3741
return { status: response.status, body: await response.json(), headers: response.headers };
@@ -40,9 +44,14 @@ async function readJson(response: Response) {
4044
describe("local setup admin route", () => {
4145
beforeEach(() => {
4246
vi.clearAllMocks();
47+
clearLocalRateLimitsForTests();
4348
mocks.count.mockResolvedValue(0);
4449
mocks.hash.mockResolvedValue("hash");
4550
mocks.create.mockResolvedValue({ id: "admin-1", username: "ry" });
51+
mocks.transaction.mockImplementation(async (callback) => callback({
52+
$queryRaw: mocks.queryRaw,
53+
localAdmin: { count: mocks.count, create: mocks.create },
54+
}));
4655
mocks.signSession.mockResolvedValue("signed-session");
4756
mocks.sessionCookieOptions.mockReturnValue({ httpOnly: true, path: "/" });
4857
});
@@ -99,4 +108,25 @@ describe("local setup admin route", () => {
99108
expect(mocks.signSession).toHaveBeenCalledWith({ adminId: "admin-1", username: "ry" });
100109
expect(result.headers.get("set-cookie")).toContain("subboost_local_session=signed-session");
101110
});
111+
112+
it("rechecks setup state under a database lock", async () => {
113+
mocks.readJsonBody.mockResolvedValue({
114+
username: "ry",
115+
password: "long-password",
116+
passwordConfirm: "long-password",
117+
});
118+
mocks.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1);
119+
120+
const result = await readJson(await POST(new Request("https://local.test/api/setup/admin")));
121+
122+
expect(result).toMatchObject({
123+
status: 409,
124+
body: { error: "已有管理员账号,请直接登录", code: "CONFLICT" },
125+
});
126+
expect(mocks.queryRaw).toHaveBeenCalledWith(
127+
expect.arrayContaining(["SELECT pg_advisory_xact_lock(", ")"]),
128+
1_397_704_283
129+
);
130+
expect(mocks.create).not.toHaveBeenCalled();
131+
});
102132
});

local/app/api/setup/admin/route.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,17 @@ import { getLocalAdminSetupCredentialError, LOCAL_ADMIN_CREDENTIAL_MESSAGES } fr
44
import { apiError, getStringField, readJsonBody } from "@local/lib/http";
55
import { prisma } from "@local/lib/prisma";
66
import { sessionCookieOptions, signSession, SESSION_COOKIE } from "@local/lib/session";
7+
import { consumeLocalRateLimit, localRateLimitResponse } from "@local/lib/rate-limit";
78

89
export async function POST(request: Request) {
10+
const setupLimit = consumeLocalRateLimit("admin-setup", "all", {
11+
limit: 5,
12+
windowMs: 15 * 60 * 1000,
13+
});
14+
if (!setupLimit.allowed) {
15+
return localRateLimitResponse("Too many setup attempts. Try again later.", setupLimit.retryAfterSeconds);
16+
}
17+
918
const body = await readJsonBody(request);
1019
if (!body) return apiError(LOCAL_ADMIN_CREDENTIAL_MESSAGES.invalidJson, "BAD_REQUEST", 400);
1120

@@ -23,10 +32,17 @@ export async function POST(request: Request) {
2332
}
2433

2534
const passwordHash = await bcrypt.hash(password, 12);
26-
const admin = await prisma.localAdmin.create({
27-
data: { username, passwordHash, lastLoginAt: new Date() },
28-
select: { id: true, username: true },
35+
const admin = await prisma.$transaction(async (transaction) => {
36+
await transaction.$queryRaw`SELECT pg_advisory_xact_lock(${1_397_704_283})`;
37+
if (await transaction.localAdmin.count()) return null;
38+
return transaction.localAdmin.create({
39+
data: { username, passwordHash, lastLoginAt: new Date() },
40+
select: { id: true, username: true },
41+
});
2942
});
43+
if (!admin) {
44+
return apiError(LOCAL_ADMIN_CREDENTIAL_MESSAGES.adminExists, "CONFLICT", 409);
45+
}
3046

3147
const response = NextResponse.json({
3248
success: true,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
consumeLocalRateLimit: vi.fn(),
5+
generateSubscriptionYaml: vi.fn(),
6+
hashLocalRateLimitKey: vi.fn(() => "token-hash"),
7+
localRateLimitResponse: vi.fn(
8+
() => new Response(JSON.stringify({ error: "limited", code: "RATE_LIMITED" }), { status: 429 })
9+
),
10+
}));
11+
12+
vi.mock("@local/lib/rate-limit", () => ({
13+
consumeLocalRateLimit: mocks.consumeLocalRateLimit,
14+
hashLocalRateLimitKey: mocks.hashLocalRateLimitKey,
15+
localRateLimitResponse: mocks.localRateLimitResponse,
16+
}));
17+
vi.mock("@local/lib/subscription-service", () => ({
18+
generateSubscriptionYaml: mocks.generateSubscriptionYaml,
19+
}));
20+
21+
import { GET } from "./route";
22+
23+
describe("local subscription YAML route", () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks();
26+
mocks.consumeLocalRateLimit.mockReturnValue({ allowed: true, retryAfterSeconds: 0 });
27+
mocks.generateSubscriptionYaml.mockResolvedValue({
28+
yaml: "mixed-port: 7890\n",
29+
name: "Test",
30+
subscriptionInfo: {},
31+
cacheExpirySeconds: 3600,
32+
autoUpdateIntervalSeconds: null,
33+
isAdmin: true,
34+
});
35+
});
36+
37+
it("applies global and per-token limits before generating YAML", async () => {
38+
const response = await GET(new Request("https://local.test/config.yaml"), {
39+
params: Promise.resolve({ id: "secret-token" }),
40+
});
41+
42+
expect(response.status).toBe(200);
43+
expect(mocks.hashLocalRateLimitKey).toHaveBeenCalledWith("secret-token");
44+
expect(mocks.consumeLocalRateLimit).toHaveBeenNthCalledWith(
45+
1,
46+
"subscription-yaml-global",
47+
"all",
48+
{ limit: 600, windowMs: 60_000 }
49+
);
50+
expect(mocks.consumeLocalRateLimit).toHaveBeenNthCalledWith(
51+
2,
52+
"subscription-yaml-token",
53+
"token-hash",
54+
{ limit: 120, windowMs: 60_000 }
55+
);
56+
expect(mocks.generateSubscriptionYaml).toHaveBeenCalledWith("secret-token");
57+
});
58+
59+
it("returns 429 before touching subscription data", async () => {
60+
mocks.consumeLocalRateLimit.mockReturnValueOnce({ allowed: false, retryAfterSeconds: 17 });
61+
62+
const response = await GET(new Request("https://local.test/config.yaml"), {
63+
params: Promise.resolve({ id: "secret-token" }),
64+
});
65+
66+
expect(response.status).toBe(429);
67+
expect(mocks.localRateLimitResponse).toHaveBeenCalledWith(
68+
"Too many subscription requests. Try again later.",
69+
17
70+
);
71+
expect(mocks.generateSubscriptionYaml).not.toHaveBeenCalled();
72+
});
73+
});

local/app/api/subscriptions/[id]/config.yaml/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
import { apiError } from "@local/lib/http";
22
import { generateSubscriptionYaml } from "@local/lib/subscription-service";
33
import { buildSubscriptionResponseHeaders } from "@subboost/server-core/subscription";
4+
import {
5+
consumeLocalRateLimit,
6+
hashLocalRateLimitKey,
7+
localRateLimitResponse,
8+
} from "@local/lib/rate-limit";
49

510
type RouteContext = {
611
params: Promise<{ id: string }>;
712
};
813

914
export async function GET(_request: Request, { params }: RouteContext) {
1015
const { id: token } = await params;
16+
const globalLimit = consumeLocalRateLimit("subscription-yaml-global", "all", {
17+
limit: 600,
18+
windowMs: 60_000,
19+
});
20+
if (!globalLimit.allowed) {
21+
return localRateLimitResponse("Too many subscription requests. Try again later.", globalLimit.retryAfterSeconds);
22+
}
23+
const tokenLimit = consumeLocalRateLimit("subscription-yaml-token", hashLocalRateLimitKey(token), {
24+
limit: 120,
25+
windowMs: 60_000,
26+
});
27+
if (!tokenLimit.allowed) {
28+
return localRateLimitResponse("Too many subscription requests. Try again later.", tokenLimit.retryAfterSeconds);
29+
}
1130
const result = await generateSubscriptionYaml(token);
1231
if (!result) return apiError("Subscription YAML not found.", "NOT_FOUND", 404);
1332
return new Response(result.yaml, {

local/local.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ DATABASE_URL=<postgres-connection-string>
55
ENCRYPTION_KEY=<generate-a-random-key>
66
JWT_SECRET=<generate-a-random-key>
77
CRON_SECRET=<generate-a-random-key>
8+
# 仅开发/测试可显式开启;生产环境即使设置也不会绕过 CRON_SECRET。
9+
ALLOW_UNAUTHENTICATED_CRON=false
810
APP_URL=http://localhost:3000
911
SUBBOOST_PORT=3000

0 commit comments

Comments
 (0)