Skip to content

Commit 114ed8a

Browse files
authored
feat: endpoint security hardening — auth, rate limiting, info leak fixes (#118)
1 parent 2b69e60 commit 114ed8a

13 files changed

Lines changed: 363 additions & 96 deletions

File tree

docs/public/operations/configuration.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,22 @@ For more details, see [Backup & Restore](backup-restore.md).
145145

146146
OIDC is configured in the Settings page under the **Authentication** tab. See [Authentication](authentication.md) for full setup instructions.
147147

148+
## Prometheus metrics
149+
150+
The `/api/metrics` endpoint exposes metrics in Prometheus exposition format. It requires a service account API token with the `metrics.read` permission.
151+
152+
Create a service account in **Settings > Service Accounts** with `metrics.read` permission, then configure your Prometheus scrape config:
153+
154+
```yaml
155+
scrape_configs:
156+
- job_name: vectorflow
157+
scheme: https
158+
metrics_path: /api/metrics
159+
bearer_token: "vf_your_service_account_key"
160+
static_configs:
161+
- targets: ["vectorflow.example.com"]
162+
```
163+
148164
## Ports reference
149165
150166
| Service | Default Port | Description |
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import { checkIpRateLimit } from "../ip-rate-limit";
3+
4+
function makeRequest(
5+
ip?: string,
6+
headers?: Record<string, string>,
7+
): Request {
8+
const h: Record<string, string> = { ...headers };
9+
if (ip) h["x-forwarded-for"] = ip;
10+
return new Request("http://localhost/api/test", { headers: h });
11+
}
12+
13+
describe("checkIpRateLimit", () => {
14+
beforeEach(() => {
15+
vi.useFakeTimers();
16+
});
17+
18+
afterEach(() => {
19+
vi.useRealTimers();
20+
});
21+
22+
it("returns null when under the limit", () => {
23+
const result = checkIpRateLimit(makeRequest("1.2.3.4"), "enroll", 10);
24+
expect(result).toBeNull();
25+
});
26+
27+
it("returns 429 response when limit exceeded", () => {
28+
for (let i = 0; i < 10; i++) {
29+
checkIpRateLimit(makeRequest("5.6.7.8"), "enroll", 10);
30+
}
31+
const result = checkIpRateLimit(makeRequest("5.6.7.8"), "enroll", 10);
32+
expect(result).not.toBeNull();
33+
expect(result!.status).toBe(429);
34+
});
35+
36+
it("includes Retry-After header on 429", () => {
37+
for (let i = 0; i < 5; i++) {
38+
checkIpRateLimit(makeRequest("9.0.1.2"), "setup", 5);
39+
}
40+
const result = checkIpRateLimit(makeRequest("9.0.1.2"), "setup", 5);
41+
expect(result!.headers.get("Retry-After")).toBeTruthy();
42+
});
43+
44+
it("isolates limits between different IPs", () => {
45+
for (let i = 0; i < 10; i++) {
46+
checkIpRateLimit(makeRequest("10.0.0.1"), "enroll", 10);
47+
}
48+
expect(checkIpRateLimit(makeRequest("10.0.0.1"), "enroll", 10)).not.toBeNull();
49+
expect(checkIpRateLimit(makeRequest("10.0.0.2"), "enroll", 10)).toBeNull();
50+
});
51+
52+
it("extracts IP from x-forwarded-for (rightmost entry)", () => {
53+
const req = makeRequest(undefined, {
54+
"x-forwarded-for": "203.0.113.50, 70.41.3.18, 150.172.238.178",
55+
});
56+
for (let i = 0; i < 10; i++) {
57+
checkIpRateLimit(req, "enroll", 10);
58+
}
59+
// Rightmost entry is the proxy-appended IP
60+
const blocked = checkIpRateLimit(
61+
makeRequest("150.172.238.178"),
62+
"enroll",
63+
10,
64+
);
65+
expect(blocked).not.toBeNull();
66+
});
67+
68+
it("falls back to x-real-ip when x-forwarded-for missing", () => {
69+
const req = new Request("http://localhost/api/test", {
70+
headers: { "x-real-ip": "192.168.1.1" },
71+
});
72+
for (let i = 0; i < 5; i++) {
73+
checkIpRateLimit(req, "setup", 5);
74+
}
75+
const result = checkIpRateLimit(req, "setup", 5);
76+
expect(result).not.toBeNull();
77+
});
78+
79+
it("uses 'unknown' key when no IP headers present", () => {
80+
const req = new Request("http://localhost/api/test");
81+
const result = checkIpRateLimit(req, "enroll", 10);
82+
expect(result).toBeNull();
83+
});
84+
85+
it("resets after window expires", () => {
86+
for (let i = 0; i < 10; i++) {
87+
checkIpRateLimit(makeRequest("1.1.1.1"), "enroll", 10);
88+
}
89+
expect(checkIpRateLimit(makeRequest("1.1.1.1"), "enroll", 10)).not.toBeNull();
90+
91+
vi.advanceTimersByTime(61_000);
92+
93+
expect(checkIpRateLimit(makeRequest("1.1.1.1"), "enroll", 10)).toBeNull();
94+
});
95+
});

src/app/api/_lib/ip-rate-limit.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { rateLimiter } from "@/app/api/v1/_lib/rate-limiter";
2+
3+
function getClientIp(request: Request): string {
4+
const forwarded = request.headers.get("x-forwarded-for");
5+
if (forwarded) {
6+
const parts = forwarded.split(",");
7+
return parts[parts.length - 1].trim();
8+
}
9+
10+
const realIp = request.headers.get("x-real-ip");
11+
if (realIp) return realIp.trim();
12+
13+
return "unknown";
14+
}
15+
16+
/**
17+
* Check an IP-keyed rate limit for unauthenticated endpoints.
18+
* Returns a 429 Response if the limit is exceeded, or null if allowed.
19+
*/
20+
export function checkIpRateLimit(
21+
request: Request,
22+
endpoint: string,
23+
limit: number,
24+
): Response | null {
25+
const ip = getClientIp(request);
26+
const key = `ip:${endpoint}:${ip}`;
27+
28+
const result = rateLimiter.checkKey(key, limit);
29+
30+
if (!result.allowed) {
31+
return new Response(JSON.stringify({ error: "Too many requests" }), {
32+
status: 429,
33+
headers: {
34+
"Content-Type": "application/json",
35+
"Retry-After": String(result.retryAfter),
36+
},
37+
});
38+
}
39+
40+
return null;
41+
}

src/app/api/agent/enroll/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { verifyEnrollmentToken, generateNodeToken } from "@/server/services/agen
55
import { fireEventAlert } from "@/server/services/event-alerts";
66
import { debugLog } from "@/lib/logger";
77
import { nodeMatchesGroup } from "@/lib/node-group-utils";
8+
import { checkIpRateLimit } from "@/app/api/_lib/ip-rate-limit";
89

910
const enrollSchema = z.object({
1011
token: z.string().min(1),
@@ -15,6 +16,9 @@ const enrollSchema = z.object({
1516
});
1617

1718
export async function POST(request: Request) {
19+
const rateLimited = checkIpRateLimit(request, "enroll", 10);
20+
if (rateLimited) return rateLimited;
21+
1822
try {
1923
const body = await request.json();
2024
const parsed = enrollSchema.safeParse(body);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { vi, describe, it, expect } from "vitest";
2+
3+
vi.mock("@/lib/prisma", () => ({
4+
prisma: {
5+
$queryRaw: vi.fn(),
6+
},
7+
}));
8+
9+
import { GET } from "../route";
10+
import { prisma } from "@/lib/prisma";
11+
12+
describe("GET /api/health", () => {
13+
it("returns { status: 'ok' } with 200 when DB is reachable", async () => {
14+
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
15+
16+
const response = await GET();
17+
const body = await response.json();
18+
19+
expect(response.status).toBe(200);
20+
expect(body).toEqual({ status: "ok" });
21+
expect(body).not.toHaveProperty("db");
22+
});
23+
24+
it("returns { status: 'error' } with 503 when DB is unreachable", async () => {
25+
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("ECONNREFUSED"));
26+
27+
const response = await GET();
28+
const body = await response.json();
29+
30+
expect(response.status).toBe(503);
31+
expect(body).toEqual({ status: "error" });
32+
expect(body).not.toHaveProperty("db");
33+
});
34+
});

src/app/api/health/route.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@ import { prisma } from "@/lib/prisma";
33
export async function GET() {
44
try {
55
await prisma.$queryRaw`SELECT 1`;
6-
return Response.json({ status: "ok", db: "connected" });
6+
return Response.json({ status: "ok" });
77
} catch {
8-
return Response.json(
9-
{ status: "error", db: "disconnected" },
10-
{ status: 503 },
11-
);
8+
return Response.json({ status: "error" }, { status: 503 });
129
}
1310
}
Lines changed: 33 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,46 @@
11
import { vi, describe, it, expect, beforeEach } from "vitest";
22

3-
// ── Hoisted mocks (available inside vi.mock factories) ──────────
43
const { mockCollectMetrics, mockAuthenticateApiKey } = vi.hoisted(() => ({
54
mockCollectMetrics: vi.fn(),
65
mockAuthenticateApiKey: vi.fn(),
76
}));
87

9-
// ── Mock PrometheusMetricsService ───────────────────────────────
108
vi.mock("@/server/services/prometheus-metrics", () => ({
119
PrometheusMetricsService: class {
1210
collectMetrics = mockCollectMetrics;
1311
},
1412
}));
1513

16-
// ── Mock authenticateApiKey ─────────────────────────────────────
1714
vi.mock("@/server/middleware/api-auth", () => ({
1815
authenticateApiKey: (...args: unknown[]) => mockAuthenticateApiKey(...args),
16+
hasPermission: (ctx: { permissions: string[] }, perm: string) =>
17+
ctx.permissions.includes(perm),
1918
}));
2019

2120
import { GET } from "@/app/api/metrics/route";
2221

23-
// ─── Helpers ────────────────────────────────────────────────────
24-
2522
function makeRequest(headers?: Record<string, string>): Request {
2623
return new Request("http://localhost:3000/api/metrics", {
2724
method: "GET",
2825
headers: headers ?? {},
2926
});
3027
}
3128

32-
// ─── Tests ──────────────────────────────────────────────────────
33-
3429
describe("GET /api/metrics", () => {
3530
beforeEach(() => {
3631
vi.clearAllMocks();
37-
// Default: auth not required
38-
delete process.env.METRICS_AUTH_REQUIRED;
39-
});
40-
41-
it("returns metrics with correct content type when auth disabled (default)", async () => {
42-
const metricsOutput = '# HELP vectorflow_node_status Node status\nvectorflow_node_status{node_id="n1"} 1\n';
43-
mockCollectMetrics.mockResolvedValue(metricsOutput);
44-
45-
const response = await GET(makeRequest());
46-
47-
expect(response.status).toBe(200);
48-
expect(response.headers.get("Content-Type")).toBe(
49-
"text/plain; version=0.0.4; charset=utf-8",
50-
);
51-
const body = await response.text();
52-
expect(body).toBe(metricsOutput);
53-
expect(mockAuthenticateApiKey).not.toHaveBeenCalled();
54-
});
55-
56-
it("does not require auth header when METRICS_AUTH_REQUIRED is unset", async () => {
57-
mockCollectMetrics.mockResolvedValue("");
58-
59-
const response = await GET(makeRequest());
60-
expect(response.status).toBe(200);
61-
expect(mockAuthenticateApiKey).not.toHaveBeenCalled();
6232
});
6333

64-
it("returns 401 when auth required and no token provided", async () => {
65-
process.env.METRICS_AUTH_REQUIRED = "true";
34+
it("returns 401 when no auth header provided", async () => {
6635
mockAuthenticateApiKey.mockResolvedValue(null);
6736

6837
const response = await GET(makeRequest());
6938

7039
expect(response.status).toBe(401);
71-
const body = await response.text();
72-
expect(body).toContain("Unauthorized");
7340
expect(mockAuthenticateApiKey).toHaveBeenCalledWith(null);
7441
});
7542

76-
it("returns 401 when auth required and invalid token provided", async () => {
77-
process.env.METRICS_AUTH_REQUIRED = "true";
43+
it("returns 401 when invalid token provided", async () => {
7844
mockAuthenticateApiKey.mockResolvedValue(null);
7945

8046
const response = await GET(
@@ -85,13 +51,27 @@ describe("GET /api/metrics", () => {
8551
expect(mockAuthenticateApiKey).toHaveBeenCalledWith("Bearer invalid_token");
8652
});
8753

88-
it("returns metrics when auth required and valid token provided", async () => {
89-
process.env.METRICS_AUTH_REQUIRED = "true";
54+
it("returns 401 when token lacks metrics.read permission", async () => {
55+
mockAuthenticateApiKey.mockResolvedValue({
56+
serviceAccountId: "sa-1",
57+
serviceAccountName: "deploy-bot",
58+
environmentId: "env-1",
59+
permissions: ["pipelines.deploy"],
60+
});
61+
62+
const response = await GET(
63+
makeRequest({ Authorization: "Bearer vf_deploy_token" }),
64+
);
65+
66+
expect(response.status).toBe(401);
67+
});
68+
69+
it("returns metrics when valid token provided", async () => {
9070
mockAuthenticateApiKey.mockResolvedValue({
9171
serviceAccountId: "sa-1",
9272
serviceAccountName: "prom-scraper",
9373
environmentId: "env-1",
94-
permissions: ["read"],
74+
permissions: ["metrics.read"],
9575
});
9676
mockCollectMetrics.mockResolvedValue("vectorflow_node_status 1\n");
9777

@@ -100,20 +80,28 @@ describe("GET /api/metrics", () => {
10080
);
10181

10282
expect(response.status).toBe(200);
83+
expect(response.headers.get("Content-Type")).toBe(
84+
"text/plain; version=0.0.4; charset=utf-8",
85+
);
10386
const body = await response.text();
10487
expect(body).toBe("vectorflow_node_status 1\n");
10588
});
10689

10790
it("returns 500 when collectMetrics throws", async () => {
91+
mockAuthenticateApiKey.mockResolvedValue({
92+
serviceAccountId: "sa-1",
93+
serviceAccountName: "prom-scraper",
94+
environmentId: "env-1",
95+
permissions: ["metrics.read"],
96+
});
10897
mockCollectMetrics.mockRejectedValue(new Error("Service crash"));
10998
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
11099

111-
const response = await GET(makeRequest());
100+
const response = await GET(
101+
makeRequest({ Authorization: "Bearer vf_valid_token" }),
102+
);
112103

113104
expect(response.status).toBe(500);
114-
const body = await response.text();
115-
expect(body).toContain("Internal Server Error");
116-
117105
consoleSpy.mockRestore();
118106
});
119107
});

0 commit comments

Comments
 (0)