create-tigra 3.0.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/server/.env.example +248 -236
- package/template/server/.env.example.production +221 -208
- package/template/server/docker-compose.yml +17 -0
- package/template/server/src/app.ts +316 -303
- package/template/server/src/config/env.ts +150 -143
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/client-ip.ts +77 -0
- package/template/server/src/libs/ip-block.ts +220 -212
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
|
@@ -1,143 +1,150 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import dotenv from 'dotenv';
|
|
3
|
-
|
|
4
|
-
// Suppress dotenv's informational logs
|
|
5
|
-
process.env.DOTENV_CONFIG_QUIET = 'true';
|
|
6
|
-
dotenv.config();
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Treat present-but-empty env vars as unset.
|
|
10
|
-
*
|
|
11
|
-
* The .env ↔ .env.example sync convention produces `VAR=""` placeholders, and
|
|
12
|
-
* Zod's `.optional()` rejects a present-but-empty string (e.g. `.url()` or
|
|
13
|
-
* `.min(32)` fails on "") — which killed boot in incident aeaaf94a. Mapping
|
|
14
|
-
* "" → undefined makes empty placeholders behave like missing vars: optionals
|
|
15
|
-
* stay undefined, defaults kick in. Production presence guards (`!env.VAR`)
|
|
16
|
-
* keep working since "" parses to undefined.
|
|
17
|
-
*/
|
|
18
|
-
const optionalEnv = <T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T> =>
|
|
19
|
-
z.preprocess((v) => (v === '' ? undefined : v), schema);
|
|
20
|
-
|
|
21
|
-
const envSchema = z.object({
|
|
22
|
-
// --- Application ---
|
|
23
|
-
NODE_ENV: optionalEnv(z.enum(['development', 'production', 'test']).default('development')),
|
|
24
|
-
PORT: optionalEnv(z.coerce.number().int().min(1).max(65535).default(8000)),
|
|
25
|
-
HOST: optionalEnv(z.string().default('0.0.0.0')),
|
|
26
|
-
|
|
27
|
-
// --- Server timeouts ---
|
|
28
|
-
// Fastify request/connection timeouts. Defaults match the previous hardcoded
|
|
29
|
-
// values. Long-running routes (LLM calls, large exports) may need 180s+ —
|
|
30
|
-
// remember the reverse proxy (Nginx/Coolify) timeout must be raised to match,
|
|
31
|
-
// or the proxy will cut the connection before the server does.
|
|
32
|
-
REQUEST_TIMEOUT_MS: optionalEnv(z.coerce.number().int().min(1).default(30000)),
|
|
33
|
-
CONNECTION_TIMEOUT_MS: optionalEnv(z.coerce.number().int().min(1).default(60000)),
|
|
34
|
-
|
|
35
|
-
// --- Database (MySQL 8.0+) ---
|
|
36
|
-
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
|
37
|
-
DATABASE_POOL_MIN: optionalEnv(z.coerce.number().int().min(1).default(2)),
|
|
38
|
-
DATABASE_POOL_MAX: optionalEnv(z.coerce.number().int().min(1).max(1000).default(10)),
|
|
39
|
-
|
|
40
|
-
// --- Redis ---
|
|
41
|
-
REDIS_URL: optionalEnv(z.string().default('redis://localhost:6379')),
|
|
42
|
-
REDIS_MAX_RETRIES: optionalEnv(z.coerce.number().int().min(0).default(3)),
|
|
43
|
-
REDIS_CONNECT_TIMEOUT: optionalEnv(z.coerce.number().int().min(1000).default(10000)), // ms
|
|
44
|
-
|
|
45
|
-
// --- Rate Limiting ---
|
|
46
|
-
RATE_LIMIT_ENABLED: optionalEnv(z.string().default('true').transform((val) => val === 'true')),
|
|
47
|
-
RATE_LIMIT_MULTIPLIER: optionalEnv(z.coerce.number().min(0.1).max(100).default(1)),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
|
|
4
|
+
// Suppress dotenv's informational logs
|
|
5
|
+
process.env.DOTENV_CONFIG_QUIET = 'true';
|
|
6
|
+
dotenv.config();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Treat present-but-empty env vars as unset.
|
|
10
|
+
*
|
|
11
|
+
* The .env ↔ .env.example sync convention produces `VAR=""` placeholders, and
|
|
12
|
+
* Zod's `.optional()` rejects a present-but-empty string (e.g. `.url()` or
|
|
13
|
+
* `.min(32)` fails on "") — which killed boot in incident aeaaf94a. Mapping
|
|
14
|
+
* "" → undefined makes empty placeholders behave like missing vars: optionals
|
|
15
|
+
* stay undefined, defaults kick in. Production presence guards (`!env.VAR`)
|
|
16
|
+
* keep working since "" parses to undefined.
|
|
17
|
+
*/
|
|
18
|
+
const optionalEnv = <T extends z.ZodTypeAny>(schema: T): z.ZodPreprocess<T> =>
|
|
19
|
+
z.preprocess((v) => (v === '' ? undefined : v), schema);
|
|
20
|
+
|
|
21
|
+
const envSchema = z.object({
|
|
22
|
+
// --- Application ---
|
|
23
|
+
NODE_ENV: optionalEnv(z.enum(['development', 'production', 'test']).default('development')),
|
|
24
|
+
PORT: optionalEnv(z.coerce.number().int().min(1).max(65535).default(8000)),
|
|
25
|
+
HOST: optionalEnv(z.string().default('0.0.0.0')),
|
|
26
|
+
|
|
27
|
+
// --- Server timeouts ---
|
|
28
|
+
// Fastify request/connection timeouts. Defaults match the previous hardcoded
|
|
29
|
+
// values. Long-running routes (LLM calls, large exports) may need 180s+ —
|
|
30
|
+
// remember the reverse proxy (Nginx/Coolify) timeout must be raised to match,
|
|
31
|
+
// or the proxy will cut the connection before the server does.
|
|
32
|
+
REQUEST_TIMEOUT_MS: optionalEnv(z.coerce.number().int().min(1).default(30000)),
|
|
33
|
+
CONNECTION_TIMEOUT_MS: optionalEnv(z.coerce.number().int().min(1).default(60000)),
|
|
34
|
+
|
|
35
|
+
// --- Database (MySQL 8.0+) ---
|
|
36
|
+
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
|
37
|
+
DATABASE_POOL_MIN: optionalEnv(z.coerce.number().int().min(1).default(2)),
|
|
38
|
+
DATABASE_POOL_MAX: optionalEnv(z.coerce.number().int().min(1).max(1000).default(10)),
|
|
39
|
+
|
|
40
|
+
// --- Redis ---
|
|
41
|
+
REDIS_URL: optionalEnv(z.string().default('redis://localhost:6379')),
|
|
42
|
+
REDIS_MAX_RETRIES: optionalEnv(z.coerce.number().int().min(0).default(3)),
|
|
43
|
+
REDIS_CONNECT_TIMEOUT: optionalEnv(z.coerce.number().int().min(1000).default(10000)), // ms
|
|
44
|
+
|
|
45
|
+
// --- Rate Limiting ---
|
|
46
|
+
RATE_LIMIT_ENABLED: optionalEnv(z.string().default('true').transform((val) => val === 'true')),
|
|
47
|
+
RATE_LIMIT_MULTIPLIER: optionalEnv(z.coerce.number().min(0.1).max(100).default(1)),
|
|
48
|
+
|
|
49
|
+
// When true, derive the client IP for rate-limiting / IP-blocking from the
|
|
50
|
+
// Cloudflare `CF-Connecting-IP` header instead of the socket/X-Forwarded-For
|
|
51
|
+
// IP (which is a Cloudflare edge IP behind the proxy — see src/libs/client-ip.ts).
|
|
52
|
+
// Default false: the header is client-spoofable, so enable this ONLY when the
|
|
53
|
+
// origin accepts traffic exclusively via Cloudflare.
|
|
54
|
+
TRUST_CLOUDFLARE: optionalEnv(z.string().default('false').transform((val) => val === 'true')),
|
|
55
|
+
RATE_LIMIT_AUTH_LOGIN_MAX: optionalEnv(z.coerce.number().int().min(1).optional()),
|
|
56
|
+
RATE_LIMIT_AUTH_REGISTER_MAX: optionalEnv(z.coerce.number().int().min(1).optional()),
|
|
57
|
+
|
|
58
|
+
// --- IP Auto-Block (see src/libs/ip-block.ts) ---
|
|
59
|
+
// An IP that exceeds rate limits IP_AUTO_BLOCK_THRESHOLD times within
|
|
60
|
+
// IP_AUTO_BLOCK_WINDOW_SECONDS is blocked for IP_AUTO_BLOCK_DURATION_SECONDS.
|
|
61
|
+
// The threshold targets sustained abuse, not a single burst — a retry-looping
|
|
62
|
+
// but legitimate client (or a NAT'd office sharing one IP) must not self-ban.
|
|
63
|
+
IP_AUTO_BLOCK_THRESHOLD: optionalEnv(z.coerce.number().int().min(1).default(20)),
|
|
64
|
+
IP_AUTO_BLOCK_WINDOW_SECONDS: optionalEnv(z.coerce.number().int().min(1).default(300)),
|
|
65
|
+
IP_AUTO_BLOCK_DURATION_SECONDS: optionalEnv(z.coerce.number().int().min(1).default(3600)),
|
|
66
|
+
|
|
67
|
+
// --- File Upload ---
|
|
68
|
+
MAX_FILE_SIZE_MB: optionalEnv(z.coerce.number().min(1).max(100).default(10)),
|
|
69
|
+
|
|
70
|
+
// --- JWT Authentication ---
|
|
71
|
+
JWT_SECRET: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(32, 'JWT_SECRET must be at least 32 characters')
|
|
74
|
+
// The committed .env.example placeholder is 43 chars and would pass min(32) —
|
|
75
|
+
// every scaffolded app would boot with the same publicly-known signing key.
|
|
76
|
+
.refine(
|
|
77
|
+
(s) => !s.startsWith('CHANGE_ME'),
|
|
78
|
+
'JWT_SECRET is still the placeholder — generate one: openssl rand -hex 48',
|
|
79
|
+
),
|
|
80
|
+
JWT_ACCESS_EXPIRY: optionalEnv(z.string().default('15m')),
|
|
81
|
+
JWT_REFRESH_EXPIRY: optionalEnv(z.string().default('7d')),
|
|
82
|
+
|
|
83
|
+
// --- Cookie ---
|
|
84
|
+
// Separate secret for cookie signing (defaults to JWT_SECRET if not set)
|
|
85
|
+
COOKIE_SECRET: optionalEnv(
|
|
86
|
+
z
|
|
87
|
+
.string()
|
|
88
|
+
.min(32, 'COOKIE_SECRET must be at least 32 characters')
|
|
89
|
+
.refine(
|
|
90
|
+
(s) => !s.startsWith('CHANGE_ME'),
|
|
91
|
+
'COOKIE_SECRET is still the placeholder — generate one: openssl rand -hex 48',
|
|
92
|
+
)
|
|
93
|
+
.optional(),
|
|
94
|
+
),
|
|
95
|
+
|
|
96
|
+
// Cookie domain for cross-origin deployments (client ≠ server hostname)
|
|
97
|
+
// Required when client and API are on different subdomains (e.g., app.example.com + api.example.com)
|
|
98
|
+
// Set to the shared parent domain with a leading dot: ".example.com"
|
|
99
|
+
// Leave empty for same-origin deployments or local development
|
|
100
|
+
COOKIE_DOMAIN: optionalEnv(z.string().optional()),
|
|
101
|
+
|
|
102
|
+
// --- Account Activation ---
|
|
103
|
+
// When true (default), new users are created as inactive and must verify
|
|
104
|
+
// their account before they can log in. When false, users are active immediately.
|
|
105
|
+
REQUIRE_USER_VERIFICATION: optionalEnv(z.string().default('true').transform((val) => val === 'true')),
|
|
106
|
+
|
|
107
|
+
// --- CORS ---
|
|
108
|
+
// In development: CORS_ORIGIN is optional (allows all origins)
|
|
109
|
+
// In production: REQUIRED for security
|
|
110
|
+
// Supports comma-separated multiple origins: "https://a.com,https://b.com"
|
|
111
|
+
CORS_ORIGIN: optionalEnv(z.string().optional()),
|
|
112
|
+
|
|
113
|
+
// --- Logging ---
|
|
114
|
+
LOG_LEVEL: optionalEnv(z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info')),
|
|
115
|
+
|
|
116
|
+
// --- Email (Resend) ---
|
|
117
|
+
RESEND_API_KEY: optionalEnv(z.string().min(1).optional()),
|
|
118
|
+
RESEND_FROM_EMAIL: optionalEnv(z.string().email().default('onboarding@resend.dev')),
|
|
119
|
+
CLIENT_URL: optionalEnv(z.string().url().default('http://localhost:3000')),
|
|
120
|
+
|
|
121
|
+
// --- Error Tracking (Optional) ---
|
|
122
|
+
SENTRY_DSN: optionalEnv(z.string().url().optional()),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const parsed = envSchema.safeParse(process.env);
|
|
126
|
+
|
|
127
|
+
if (!parsed.success) {
|
|
128
|
+
const formatted = parsed.error.issues
|
|
129
|
+
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
|
|
130
|
+
.join('\n');
|
|
131
|
+
|
|
132
|
+
console.error(`\nEnvironment validation failed:\n${formatted}\n`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Validate CORS_ORIGIN in production
|
|
137
|
+
if (parsed.data.NODE_ENV === 'production' && !parsed.data.CORS_ORIGIN) {
|
|
138
|
+
console.error('\nCORS_ORIGIN is required in production for security\n');
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validate RESEND_API_KEY when email verification is enabled
|
|
143
|
+
if (parsed.data.REQUIRE_USER_VERIFICATION && !parsed.data.RESEND_API_KEY) {
|
|
144
|
+
console.error('\nRESEND_API_KEY is required when REQUIRE_USER_VERIFICATION is enabled.\nGet your API key from: https://resend.com/api-keys\n');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const env = parsed.data;
|
|
149
|
+
|
|
150
|
+
export type Env = z.infer<typeof envSchema>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isAuthPath } from '../auth-path.js';
|
|
3
|
+
|
|
4
|
+
describe('isAuthPath', () => {
|
|
5
|
+
it('returns true for an auth route', () => {
|
|
6
|
+
expect(isAuthPath({ url: '/api/v1/auth/login' })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns true when the auth route has a query string', () => {
|
|
10
|
+
expect(isAuthPath({ url: '/api/v1/auth/login?next=/dashboard' })).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns false for a non-auth route (it still feeds the auto-ban)', () => {
|
|
14
|
+
expect(isAuthPath({ url: '/api/v1/widget/chat' })).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns false for the bare /api/v1/auth prefix without a sub-path', () => {
|
|
18
|
+
expect(isAuthPath({ url: '/api/v1/auth' })).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns false for a non-auth path that only starts with "auth"', () => {
|
|
22
|
+
expect(isAuthPath({ url: '/api/v1/auth-internal/health' })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { FastifyRequest } from 'fastify';
|
|
3
|
+
|
|
4
|
+
// Mock the env module so each test can flip TRUST_CLOUDFLARE without relying on
|
|
5
|
+
// process.env load order (src/config/env.ts validates + exits on import).
|
|
6
|
+
const mockEnv = vi.hoisted(() => ({ TRUST_CLOUDFLARE: false }));
|
|
7
|
+
vi.mock('@config/env.js', () => ({ env: mockEnv }));
|
|
8
|
+
|
|
9
|
+
import { getClientIp } from '../client-ip.js';
|
|
10
|
+
|
|
11
|
+
// Minimal FastifyRequest stub — getClientIp only reads `.ip` and `.headers`.
|
|
12
|
+
function makeRequest(ip: string, headers: Record<string, unknown> = {}): FastifyRequest {
|
|
13
|
+
return { ip, headers } as unknown as FastifyRequest;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('getClientIp', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
mockEnv.TRUST_CLOUDFLARE = false;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('when TRUST_CLOUDFLARE is false (default)', () => {
|
|
22
|
+
it('returns request.ip and ignores the CF-Connecting-IP header', () => {
|
|
23
|
+
const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': '203.0.113.7' });
|
|
24
|
+
expect(getClientIp(request)).toBe('10.0.0.1');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns request.ip when no CF header is present', () => {
|
|
28
|
+
expect(getClientIp(makeRequest('10.0.0.1'))).toBe('10.0.0.1');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('when TRUST_CLOUDFLARE is true', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
mockEnv.TRUST_CLOUDFLARE = true;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns the CF-Connecting-IP header value', () => {
|
|
38
|
+
const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': '203.0.113.7' });
|
|
39
|
+
expect(getClientIp(request)).toBe('203.0.113.7');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to request.ip when the CF header is absent', () => {
|
|
43
|
+
expect(getClientIp(makeRequest('10.0.0.1'))).toBe('10.0.0.1');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('falls back to request.ip when the CF header is an empty string', () => {
|
|
47
|
+
const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': '' });
|
|
48
|
+
expect(getClientIp(request)).toBe('10.0.0.1');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('falls back to request.ip when the CF header is an array (duplicated header)', () => {
|
|
52
|
+
const request = makeRequest('10.0.0.1', { 'cf-connecting-ip': ['203.0.113.7', '198.51.100.2'] });
|
|
53
|
+
expect(getClientIp(request)).toBe('10.0.0.1');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('falls through an array CF header to a valid X-Forwarded-For entry', () => {
|
|
57
|
+
// CF header is an array (untrusted shape) so it falls through; XFF is then used.
|
|
58
|
+
const request = makeRequest('10.0.0.1', {
|
|
59
|
+
'cf-connecting-ip': ['203.0.113.7', '198.51.100.2'],
|
|
60
|
+
'x-forwarded-for': '198.51.100.9',
|
|
61
|
+
});
|
|
62
|
+
expect(getClientIp(request)).toBe('198.51.100.9');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('prefers a valid CF header over X-Forwarded-For', () => {
|
|
66
|
+
const request = makeRequest('10.0.0.1', {
|
|
67
|
+
'cf-connecting-ip': '203.0.113.7',
|
|
68
|
+
'x-forwarded-for': '198.51.100.9',
|
|
69
|
+
});
|
|
70
|
+
expect(getClientIp(request)).toBe('203.0.113.7');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('falls through to X-Forwarded-For when the CF header is an invalid IP', () => {
|
|
74
|
+
const request = makeRequest('10.0.0.1', {
|
|
75
|
+
'cf-connecting-ip': 'not-an-ip',
|
|
76
|
+
'x-forwarded-for': '198.51.100.9',
|
|
77
|
+
});
|
|
78
|
+
expect(getClientIp(request)).toBe('198.51.100.9');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('X-Forwarded-For resolution (independent of the flag)', () => {
|
|
83
|
+
it('uses the left-most XFF entry when the CF flag is on but the CF header is absent', () => {
|
|
84
|
+
mockEnv.TRUST_CLOUDFLARE = true;
|
|
85
|
+
const request = makeRequest('10.0.0.1', { 'x-forwarded-for': '203.0.113.7' });
|
|
86
|
+
expect(getClientIp(request)).toBe('203.0.113.7');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('uses the left-most XFF entry even when the CF flag is off (grey-cloud / DNS-only)', () => {
|
|
90
|
+
const request = makeRequest('10.0.0.1', { 'x-forwarded-for': '203.0.113.7' });
|
|
91
|
+
expect(getClientIp(request)).toBe('203.0.113.7');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns the left-most entry of a multi-hop XFF chain', () => {
|
|
95
|
+
const request = makeRequest('10.0.0.1', {
|
|
96
|
+
'x-forwarded-for': '203.0.113.7, 70.41.3.18, 150.172.238.178',
|
|
97
|
+
});
|
|
98
|
+
expect(getClientIp(request)).toBe('203.0.113.7');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('trims surrounding whitespace from the XFF entry', () => {
|
|
102
|
+
const request = makeRequest('10.0.0.1', { 'x-forwarded-for': ' 203.0.113.7 , 70.41.3.18' });
|
|
103
|
+
expect(getClientIp(request)).toBe('203.0.113.7');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('falls back to request.ip when the XFF entry is not a valid IP', () => {
|
|
107
|
+
const request = makeRequest('10.0.0.1', { 'x-forwarded-for': 'garbage, 70.41.3.18' });
|
|
108
|
+
expect(getClientIp(request)).toBe('10.0.0.1');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('falls back to request.ip when the XFF header is an empty string', () => {
|
|
112
|
+
const request = makeRequest('10.0.0.1', { 'x-forwarded-for': '' });
|
|
113
|
+
expect(getClientIp(request)).toBe('10.0.0.1');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('uses the first element when XFF is an array (duplicated header)', () => {
|
|
117
|
+
const request = makeRequest('10.0.0.1', { 'x-forwarded-for': ['203.0.113.7', '70.41.3.18'] });
|
|
118
|
+
expect(getClientIp(request)).toBe('203.0.113.7');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { getRedis } from '@libs/redis.js';
|
|
3
|
+
import { recordRateLimitViolation } from '../ip-block.js';
|
|
4
|
+
|
|
5
|
+
// Self-contained env so ip-block.ts can read its threshold constants at import
|
|
6
|
+
// time without loading/validating the real environment.
|
|
7
|
+
vi.mock('@config/env.js', () => ({
|
|
8
|
+
env: {
|
|
9
|
+
IP_AUTO_BLOCK_THRESHOLD: 20,
|
|
10
|
+
IP_AUTO_BLOCK_WINDOW_SECONDS: 300,
|
|
11
|
+
IP_AUTO_BLOCK_DURATION_SECONDS: 3600,
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@libs/prisma.js', () => ({
|
|
16
|
+
prisma: {
|
|
17
|
+
blockedIp: {
|
|
18
|
+
findMany: vi.fn(),
|
|
19
|
+
findUnique: vi.fn(),
|
|
20
|
+
create: vi.fn(),
|
|
21
|
+
deleteMany: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('@libs/redis.js');
|
|
27
|
+
vi.mock('@libs/logger.js', () => ({
|
|
28
|
+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const redisMock = {
|
|
32
|
+
del: vi.fn(),
|
|
33
|
+
zadd: vi.fn(),
|
|
34
|
+
incr: vi.fn(),
|
|
35
|
+
expire: vi.fn(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('recordRateLimitViolation — infra-IP guard', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
vi.mocked(getRedis).mockReturnValue(redisMock as never);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('never auto-blocks a private/reserved IP (skips Redis accounting entirely)', async () => {
|
|
45
|
+
// A shared Traefik/Docker internal IP must never be auto-banned — that would
|
|
46
|
+
// lock out every real user behind it.
|
|
47
|
+
await recordRateLimitViolation('10.0.0.5');
|
|
48
|
+
expect(redisMock.incr).not.toHaveBeenCalled();
|
|
49
|
+
expect(redisMock.zadd).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('skips loopback addresses', async () => {
|
|
53
|
+
await recordRateLimitViolation('127.0.0.1');
|
|
54
|
+
expect(redisMock.incr).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('records a violation for a public client IP', async () => {
|
|
58
|
+
redisMock.incr.mockResolvedValue(1);
|
|
59
|
+
await recordRateLimitViolation('93.184.216.34');
|
|
60
|
+
expect(redisMock.incr).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { redactUrl, isPrivateOrReservedIp } from '../url-safety.js';
|
|
3
|
+
|
|
4
|
+
describe('redactUrl', () => {
|
|
5
|
+
it('redacts a Telegram bot token in the API path', () => {
|
|
6
|
+
expect(redactUrl('https://api.telegram.org/bot123456:AAHsecret/getFile')).toBe(
|
|
7
|
+
'https://api.telegram.org/bot<redacted>/getFile',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('redacts a Telegram bot token in the file-download path', () => {
|
|
12
|
+
expect(redactUrl('https://api.telegram.org/file/bot123456:AAHsecret/photos/file_1.jpg')).toBe(
|
|
13
|
+
'https://api.telegram.org/file/bot<redacted>/photos/file_1.jpg',
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('strips the query string (may carry signed media tokens)', () => {
|
|
18
|
+
expect(redactUrl('https://lookaside.fbsbx.com/media?asset_id=1&token=SECRET')).toBe(
|
|
19
|
+
'https://lookaside.fbsbx.com/media',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('strips a fragment as well', () => {
|
|
24
|
+
expect(redactUrl('https://cdn.example.com/x.jpg#frag')).toBe('https://cdn.example.com/x.jpg');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('leaves a normal URL unchanged', () => {
|
|
28
|
+
expect(redactUrl('https://scontent.xx.fbcdn.net/v/photo.jpg')).toBe(
|
|
29
|
+
'https://scontent.xx.fbcdn.net/v/photo.jpg',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns empty / non-string input as-is without throwing', () => {
|
|
34
|
+
expect(redactUrl('')).toBe('');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('isPrivateOrReservedIp', () => {
|
|
39
|
+
it.each([
|
|
40
|
+
'0.0.0.0',
|
|
41
|
+
'127.0.0.1',
|
|
42
|
+
'10.0.0.5',
|
|
43
|
+
'172.16.0.1',
|
|
44
|
+
'172.31.255.254',
|
|
45
|
+
'192.168.1.1',
|
|
46
|
+
'169.254.169.254',
|
|
47
|
+
'100.64.0.1',
|
|
48
|
+
'100.127.255.255',
|
|
49
|
+
'::1',
|
|
50
|
+
'::',
|
|
51
|
+
'fc00::1',
|
|
52
|
+
'fd12:3456:789a::1',
|
|
53
|
+
'fe80::abcd',
|
|
54
|
+
'::ffff:10.0.0.5',
|
|
55
|
+
'::ffff:127.0.0.1',
|
|
56
|
+
])('blocks internal/reserved address %s', (addr) => {
|
|
57
|
+
expect(isPrivateOrReservedIp(addr)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
'93.184.216.34',
|
|
62
|
+
'8.8.8.8',
|
|
63
|
+
'1.1.1.1',
|
|
64
|
+
'172.32.0.1', // just outside 172.16/12
|
|
65
|
+
'172.15.0.1', // just below 172.16/12
|
|
66
|
+
'192.169.0.1', // not 192.168
|
|
67
|
+
'100.63.255.255', // just below CGNAT
|
|
68
|
+
'100.128.0.0', // just above CGNAT
|
|
69
|
+
'2606:4700:4700::1111', // Cloudflare public IPv6
|
|
70
|
+
'::ffff:93.184.216.34', // IPv4-mapped public
|
|
71
|
+
])('allows public address %s', (addr) => {
|
|
72
|
+
expect(isPrivateOrReservedIp(addr)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('fails closed (blocks) on malformed input', () => {
|
|
76
|
+
expect(isPrivateOrReservedIp('not-an-ip')).toBe(true);
|
|
77
|
+
expect(isPrivateOrReservedIp('999.999.999.999')).toBe(true);
|
|
78
|
+
expect(isPrivateOrReservedIp('')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auth routes (login / register / refresh / ...) have their own per-route rate
|
|
5
|
+
* limit and an account-lockout ladder. Their 429s must NOT feed the
|
|
6
|
+
* cross-endpoint 1-hour IP auto-ban, or a user mistyping a password would lock
|
|
7
|
+
* themselves out of the entire API (widget included).
|
|
8
|
+
*
|
|
9
|
+
* Exported for unit testing (auth-path.test.ts).
|
|
10
|
+
*/
|
|
11
|
+
export function isAuthPath(request: Pick<FastifyRequest, 'url'>): boolean {
|
|
12
|
+
const pathname = request.url.split('?')[0];
|
|
13
|
+
return pathname.startsWith('/api/v1/auth/');
|
|
14
|
+
}
|