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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client IP resolution — Cloudflare- and reverse-proxy-aware.
|
|
3
|
+
*
|
|
4
|
+
* Behind a Cloudflare proxy (or any reverse proxy such as Traefik/Coolify),
|
|
5
|
+
* Fastify's `request.ip` (derived from the socket / X-Forwarded-For) resolves
|
|
6
|
+
* to an *edge* IP, not the real visitor. Every request through one edge node
|
|
7
|
+
* then shares a single IP, which collapses the per-IP rate limiter and the IP
|
|
8
|
+
* auto-block onto that shared edge address: one abusive client can rate-limit
|
|
9
|
+
* or self-ban every legitimate user behind the same edge, and conversely abuse
|
|
10
|
+
* from many clients hides behind one IP.
|
|
11
|
+
*
|
|
12
|
+
* Resolution is ordered, and every header value is VALIDATED as a real IPv4/IPv6
|
|
13
|
+
* literal (via `node:net.isIP`) before it is trusted — a junk or spoofed header
|
|
14
|
+
* falls through to the next tier instead of becoming the rate-limit key:
|
|
15
|
+
*
|
|
16
|
+
* 1. `CF-Connecting-IP` — the genuine client IP Cloudflare injects. Consulted
|
|
17
|
+
* ONLY when `TRUST_CLOUDFLARE` is enabled (orange-cloud / proxied origin).
|
|
18
|
+
* 2. Left-most VALID `X-Forwarded-For` entry — the original client, appended by
|
|
19
|
+
* the reverse proxy. XFF is a comma-separated list (client, proxy1, proxy2,
|
|
20
|
+
* ...), so the original client is the left-most entry. This tier is consulted
|
|
21
|
+
* regardless of the flag: it covers the grey-cloud / DNS-only case where the
|
|
22
|
+
* Cloudflare header is absent but traffic still arrives through Traefik —
|
|
23
|
+
* without it, every grey-clouded visitor collapses onto the single shared
|
|
24
|
+
* Traefik upstream IP and trips the auto-block site-wide.
|
|
25
|
+
* 3. `request.ip` — Fastify's socket / trustProxy-resolved peer IP (always
|
|
26
|
+
* non-empty), the safe fallback for any origin reachable directly.
|
|
27
|
+
*
|
|
28
|
+
* SECURITY PRECONDITION: both `CF-Connecting-IP` and `X-Forwarded-For` are
|
|
29
|
+
* client-supplied and therefore SPOOFABLE if a caller can reach the origin
|
|
30
|
+
* directly. They are only trustworthy when the origin accepts traffic
|
|
31
|
+
* EXCLUSIVELY via the proxy (Cloudflare / Traefik) — a documented deploy
|
|
32
|
+
* precondition. Validation here narrows the spoof surface to well-formed IP
|
|
33
|
+
* literals but does not, on its own, prove provenance.
|
|
34
|
+
*
|
|
35
|
+
* Use this for rate-limiting and IP-blocking decisions. Logging / session
|
|
36
|
+
* metadata can keep using `request.ip` — there a spoofed value is harmless.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { isIP } from 'node:net';
|
|
40
|
+
import type { FastifyRequest } from 'fastify';
|
|
41
|
+
import { env } from '@config/env.js';
|
|
42
|
+
|
|
43
|
+
/** Return the trimmed value if it is a valid IPv4/IPv6 literal, else undefined. */
|
|
44
|
+
function asValidIp(value: string | undefined): string | undefined {
|
|
45
|
+
if (!value) return undefined;
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
return isIP(trimmed) !== 0 ? trimmed : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the real client IP for rate-limiting / blocking decisions.
|
|
52
|
+
*
|
|
53
|
+
* Synchronous and always returns a non-empty string (the `request.ip` fallback
|
|
54
|
+
* guarantees this), so it is safe to use directly as a rate-limit `keyGenerator`.
|
|
55
|
+
*
|
|
56
|
+
* @param request - The incoming Fastify request
|
|
57
|
+
* @returns The client IP to use as the rate-limit / block key
|
|
58
|
+
*/
|
|
59
|
+
export function getClientIp(request: FastifyRequest): string {
|
|
60
|
+
// 1. CF-Connecting-IP — only when TRUST_CLOUDFLARE is set (origin locked to CF).
|
|
61
|
+
if (env.TRUST_CLOUDFLARE) {
|
|
62
|
+
const cfHeader = request.headers['cf-connecting-ip'];
|
|
63
|
+
const cf = asValidIp(typeof cfHeader === 'string' ? cfHeader : undefined);
|
|
64
|
+
if (cf) return cf;
|
|
65
|
+
}
|
|
66
|
+
// 2. Left-most VALID X-Forwarded-For entry — the original client, appended by the
|
|
67
|
+
// reverse proxy (Traefik/Coolify). Covers grey-cloud / DNS-only where the CF
|
|
68
|
+
// header is absent. Validated so a junk/spoofed header falls through.
|
|
69
|
+
const xffRaw = request.headers['x-forwarded-for'];
|
|
70
|
+
const xff = typeof xffRaw === 'string' ? xffRaw : Array.isArray(xffRaw) ? xffRaw[0] : undefined;
|
|
71
|
+
if (xff) {
|
|
72
|
+
const leftMost = asValidIp(xff.split(',')[0]);
|
|
73
|
+
if (leftMost) return leftMost;
|
|
74
|
+
}
|
|
75
|
+
// 3. Fastify's socket/trustProxy-resolved peer IP (always non-empty).
|
|
76
|
+
return request.ip;
|
|
77
|
+
}
|
|
@@ -1,212 +1,220 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* IP Blocking Service
|
|
3
|
-
*
|
|
4
|
-
* Two-tier IP blocking:
|
|
5
|
-
* - Permanent blocks: DB (source of truth) + Redis SET (hot cache). Admin-managed via API.
|
|
6
|
-
* - Auto-blocks: Redis ZSET with expiry timestamps (triggered by excessive rate-limit violations)
|
|
7
|
-
*
|
|
8
|
-
* Design decisions:
|
|
9
|
-
* - DB is the source of truth for permanent blocks — survives Redis restarts
|
|
10
|
-
* - Redis is the hot cache — all runtime checks hit Redis only (O(1))
|
|
11
|
-
* - On server boot, syncBlockedIpsToRedis() loads all permanent blocks from DB into Redis
|
|
12
|
-
* - Fails open: if Redis is down, requests are NOT blocked (availability > security for rate limiting)
|
|
13
|
-
* - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { env } from '@config/env.js';
|
|
17
|
-
import { prisma } from '@libs/prisma.js';
|
|
18
|
-
import { getRedis } from '@libs/redis.js';
|
|
19
|
-
import { logger } from '@libs/logger.js';
|
|
20
|
-
import { ConflictError } from '@shared/errors/errors.js';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
logger.
|
|
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
|
-
* @param
|
|
98
|
-
* @param
|
|
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
|
-
await redis.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* IP Blocking Service
|
|
3
|
+
*
|
|
4
|
+
* Two-tier IP blocking:
|
|
5
|
+
* - Permanent blocks: DB (source of truth) + Redis SET (hot cache). Admin-managed via API.
|
|
6
|
+
* - Auto-blocks: Redis ZSET with expiry timestamps (triggered by excessive rate-limit violations)
|
|
7
|
+
*
|
|
8
|
+
* Design decisions:
|
|
9
|
+
* - DB is the source of truth for permanent blocks — survives Redis restarts
|
|
10
|
+
* - Redis is the hot cache — all runtime checks hit Redis only (O(1))
|
|
11
|
+
* - On server boot, syncBlockedIpsToRedis() loads all permanent blocks from DB into Redis
|
|
12
|
+
* - Fails open: if Redis is down, requests are NOT blocked (availability > security for rate limiting)
|
|
13
|
+
* - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { env } from '@config/env.js';
|
|
17
|
+
import { prisma } from '@libs/prisma.js';
|
|
18
|
+
import { getRedis } from '@libs/redis.js';
|
|
19
|
+
import { logger } from '@libs/logger.js';
|
|
20
|
+
import { ConflictError } from '@shared/errors/errors.js';
|
|
21
|
+
import { isPrivateOrReservedIp } from '@libs/url-safety.js';
|
|
22
|
+
|
|
23
|
+
// Redis keys
|
|
24
|
+
const BLOCKED_IPS_KEY = 'blocked_ips';
|
|
25
|
+
const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
|
|
26
|
+
const VIOLATION_PREFIX = 'rl_violations:';
|
|
27
|
+
|
|
28
|
+
// Auto-block thresholds — env-configurable (see .env.example).
|
|
29
|
+
// The threshold default is deliberately high (20): auto-block must catch
|
|
30
|
+
// SUSTAINED abuse, not a single burst. Rate-limit counting happens before
|
|
31
|
+
// validation, so a retry-looping but legitimate client (or a NAT'd office
|
|
32
|
+
// sharing one egress IP) can rack up violations quickly — see the self-ban
|
|
33
|
+
// interaction note in src/config/rate-limit.config.ts before lowering it.
|
|
34
|
+
const AUTO_BLOCK_THRESHOLD = env.IP_AUTO_BLOCK_THRESHOLD; // violations before auto-block (default 20)
|
|
35
|
+
const AUTO_BLOCK_WINDOW_SECONDS = env.IP_AUTO_BLOCK_WINDOW_SECONDS; // sliding window (default 300 = 5 min)
|
|
36
|
+
const AUTO_BLOCK_DURATION_SECONDS = env.IP_AUTO_BLOCK_DURATION_SECONDS; // block duration (default 3600 = 1 hour)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sync all permanent blocked IPs from DB to Redis.
|
|
40
|
+
* Called once during server startup.
|
|
41
|
+
*/
|
|
42
|
+
export async function syncBlockedIpsToRedis(): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
const blockedIps = await prisma.blockedIp.findMany({ select: { ip: true } });
|
|
45
|
+
|
|
46
|
+
const redis = getRedis();
|
|
47
|
+
|
|
48
|
+
// Clear stale Redis state and repopulate from DB
|
|
49
|
+
await redis.del(BLOCKED_IPS_KEY);
|
|
50
|
+
|
|
51
|
+
if (blockedIps.length > 0) {
|
|
52
|
+
await redis.sadd(BLOCKED_IPS_KEY, ...blockedIps.map((b) => b.ip));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
logger.info(`[IP-BLOCK] Synced ${blockedIps.length} blocked IPs from DB to Redis`);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
logger.warn('[IP-BLOCK] Failed to sync blocked IPs from DB to Redis — permanent blocks may not be enforced until next restart');
|
|
58
|
+
logger.debug(error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if an IP is blocked (permanent or auto-blocked).
|
|
64
|
+
*
|
|
65
|
+
* @param ip - IP address to check
|
|
66
|
+
* @returns true if blocked, false otherwise (including Redis failures)
|
|
67
|
+
*/
|
|
68
|
+
export async function isIpBlocked(ip: string): Promise<boolean> {
|
|
69
|
+
try {
|
|
70
|
+
const redis = getRedis();
|
|
71
|
+
|
|
72
|
+
// Check permanent block list
|
|
73
|
+
const permanent = await redis.sismember(BLOCKED_IPS_KEY, ip);
|
|
74
|
+
if (permanent === 1) return true;
|
|
75
|
+
|
|
76
|
+
// Check auto-block list (score = expiry Unix timestamp)
|
|
77
|
+
const score = await redis.zscore(AUTO_BLOCKED_KEY, ip);
|
|
78
|
+
if (score) {
|
|
79
|
+
const expiresAt = Number(score);
|
|
80
|
+
if (expiresAt > Date.now() / 1000) return true;
|
|
81
|
+
|
|
82
|
+
// Expired — clean up lazily
|
|
83
|
+
await redis.zrem(AUTO_BLOCKED_KEY, ip);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false;
|
|
87
|
+
} catch {
|
|
88
|
+
// Fail open: if Redis is down, don't block
|
|
89
|
+
logger.warn('[IP-BLOCK] Redis unavailable, skipping IP block check');
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Block an IP permanently. Writes to DB (source of truth) + Redis (hot cache).
|
|
96
|
+
*
|
|
97
|
+
* @param ip - IP address to block
|
|
98
|
+
* @param blockedBy - Admin user ID who initiated the block
|
|
99
|
+
* @param reason - Optional reason for the block
|
|
100
|
+
*/
|
|
101
|
+
export async function blockIp(
|
|
102
|
+
ip: string,
|
|
103
|
+
blockedBy: string,
|
|
104
|
+
reason?: string,
|
|
105
|
+
): Promise<{ id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }> {
|
|
106
|
+
// Write to DB (source of truth)
|
|
107
|
+
const existing = await prisma.blockedIp.findUnique({ where: { ip } });
|
|
108
|
+
if (existing) {
|
|
109
|
+
throw new ConflictError('IP is already blocked', 'IP_ALREADY_BLOCKED');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const blocked = await prisma.blockedIp.create({
|
|
113
|
+
data: { ip, blockedBy, reason: reason ?? null },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Sync to Redis cache
|
|
117
|
+
try {
|
|
118
|
+
const redis = getRedis();
|
|
119
|
+
await redis.sadd(BLOCKED_IPS_KEY, ip);
|
|
120
|
+
} catch {
|
|
121
|
+
logger.warn({ ip }, '[IP-BLOCK] Failed to sync block to Redis — will be synced on next restart');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logger.info({ ip, blockedBy, reason }, '[IP-BLOCK] IP permanently blocked');
|
|
125
|
+
return blocked;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Unblock an IP. Removes from DB + Redis + auto-block list.
|
|
130
|
+
*
|
|
131
|
+
* @param ip - IP address to unblock
|
|
132
|
+
*/
|
|
133
|
+
export async function unblockIp(ip: string): Promise<void> {
|
|
134
|
+
// Remove from DB
|
|
135
|
+
await prisma.blockedIp.deleteMany({ where: { ip } });
|
|
136
|
+
|
|
137
|
+
// Remove from Redis (both permanent and auto-block)
|
|
138
|
+
try {
|
|
139
|
+
const redis = getRedis();
|
|
140
|
+
await redis.srem(BLOCKED_IPS_KEY, ip);
|
|
141
|
+
await redis.zrem(AUTO_BLOCKED_KEY, ip);
|
|
142
|
+
} catch {
|
|
143
|
+
logger.warn({ ip }, '[IP-BLOCK] Failed to sync unblock to Redis — will be synced on next restart');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
logger.info({ ip }, '[IP-BLOCK] IP unblocked');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* List all currently blocked IPs (permanent from DB + active auto-blocks from Redis).
|
|
151
|
+
*/
|
|
152
|
+
export async function getBlockedIps(): Promise<{
|
|
153
|
+
permanent: { id: string; ip: string; reason: string | null; blockedBy: string; createdAt: Date }[];
|
|
154
|
+
autoBlocked: string[];
|
|
155
|
+
}> {
|
|
156
|
+
// Permanent blocks from DB (source of truth)
|
|
157
|
+
const permanent = await prisma.blockedIp.findMany({
|
|
158
|
+
select: { id: true, ip: true, reason: true, blockedBy: true, createdAt: true },
|
|
159
|
+
orderBy: { createdAt: 'desc' },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Auto-blocks from Redis
|
|
163
|
+
let autoBlocked: string[] = [];
|
|
164
|
+
try {
|
|
165
|
+
const redis = getRedis();
|
|
166
|
+
const nowSeconds = Date.now() / 1000;
|
|
167
|
+
autoBlocked = await redis.zrangebyscore(AUTO_BLOCKED_KEY, nowSeconds, '+inf');
|
|
168
|
+
} catch {
|
|
169
|
+
logger.warn('[IP-BLOCK] Redis unavailable, cannot retrieve auto-blocked IPs');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { permanent, autoBlocked };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Record a rate-limit violation for an IP.
|
|
177
|
+
*
|
|
178
|
+
* If the IP exceeds AUTO_BLOCK_THRESHOLD violations within
|
|
179
|
+
* AUTO_BLOCK_WINDOW_SECONDS, it gets auto-blocked for
|
|
180
|
+
* AUTO_BLOCK_DURATION_SECONDS.
|
|
181
|
+
*
|
|
182
|
+
* Called from the rate-limit `onExceeded` callback.
|
|
183
|
+
*
|
|
184
|
+
* @param ip - IP address that violated rate limit
|
|
185
|
+
*/
|
|
186
|
+
export async function recordRateLimitViolation(ip: string): Promise<void> {
|
|
187
|
+
// Never auto-block infrastructure addresses (loopback / private / link-local /
|
|
188
|
+
// Traefik-Docker internal). A shared proxy IP getting auto-blocked would lock
|
|
189
|
+
// out every real user behind it. Public client IPs proceed as normal.
|
|
190
|
+
if (isPrivateOrReservedIp(ip)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const redis = getRedis();
|
|
196
|
+
const key = `${VIOLATION_PREFIX}${ip}`;
|
|
197
|
+
|
|
198
|
+
const count = await redis.incr(key);
|
|
199
|
+
|
|
200
|
+
// Set TTL on first violation (sliding window)
|
|
201
|
+
if (count === 1) {
|
|
202
|
+
await redis.expire(key, AUTO_BLOCK_WINDOW_SECONDS);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (count >= AUTO_BLOCK_THRESHOLD) {
|
|
206
|
+
// Auto-block: add to ZSET with expiry timestamp as score
|
|
207
|
+
const expiresAt = Math.floor(Date.now() / 1000) + AUTO_BLOCK_DURATION_SECONDS;
|
|
208
|
+
await redis.zadd(AUTO_BLOCKED_KEY, expiresAt, ip);
|
|
209
|
+
await redis.del(key); // Reset violation counter
|
|
210
|
+
|
|
211
|
+
logger.warn(
|
|
212
|
+
{ ip, violations: count, blockedForSeconds: AUTO_BLOCK_DURATION_SECONDS },
|
|
213
|
+
'[IP-BLOCK] Auto-blocked IP due to excessive rate-limit violations',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// Non-critical: don't break the request if violation tracking fails
|
|
218
|
+
logger.warn('[IP-BLOCK] Failed to record rate-limit violation');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { FastifyInstance } from 'fastify';
|
|
3
|
+
import { prisma } from './prisma.js';
|
|
4
|
+
import { env } from '@config/env.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dev-only per-request Prisma query counter → the `X-Query-Count` response header.
|
|
8
|
+
*
|
|
9
|
+
* Rides the query events prisma.ts already emits (`{ emit: 'event', level: 'query' }`): an
|
|
10
|
+
* AsyncLocalStorage store is entered per request, every emitted query increments it, and the
|
|
11
|
+
* total ships back as `X-Query-Count`. This lets a black-box perf/load tester (the fleet's
|
|
12
|
+
* perf-tester) see N+1 at the source over plain HTTP — no DB access, no profiler needed.
|
|
13
|
+
*
|
|
14
|
+
* Note on Prisma N+1: the engine's DataLoader auto-batches relation `include`s and same-tick
|
|
15
|
+
* calls, so the queries this counts are the ones that actually matter — sequential awaited
|
|
16
|
+
* queries in a loop (`for (...) await prisma.x.find()`), the real application-level N+1.
|
|
17
|
+
*
|
|
18
|
+
* NEVER active in production: no listener, no header, zero overhead — purely a dev aid.
|
|
19
|
+
*/
|
|
20
|
+
interface QueryCountStore {
|
|
21
|
+
count: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const als = new AsyncLocalStorage<QueryCountStore>();
|
|
25
|
+
|
|
26
|
+
let listenerAttached = false;
|
|
27
|
+
function attachQueryListener(): void {
|
|
28
|
+
if (listenerAttached) return;
|
|
29
|
+
listenerAttached = true;
|
|
30
|
+
// Queries outside a counted request (startup, jobs) simply find no store and are ignored.
|
|
31
|
+
prisma.$on('query' as never, () => {
|
|
32
|
+
const store = als.getStore();
|
|
33
|
+
if (store) store.count += 1;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wire the dev-only `X-Query-Count` header onto every route. Call once with the root app
|
|
39
|
+
* instance (top-level, so the hooks are global). No-op in production.
|
|
40
|
+
*/
|
|
41
|
+
export function registerQueryCounter(app: FastifyInstance): void {
|
|
42
|
+
if (env.NODE_ENV === 'production') return;
|
|
43
|
+
attachQueryListener();
|
|
44
|
+
|
|
45
|
+
// Wrap the whole request lifecycle in a fresh store via the callback-style hook: passing
|
|
46
|
+
// Fastify's `done` continuation into als.run() means every subsequent hook, the handler, and
|
|
47
|
+
// the Prisma calls they make run inside this request's store (the @fastify/request-context
|
|
48
|
+
// pattern — more robust than enterWith, which can be lost across the hook boundary).
|
|
49
|
+
app.addHook('onRequest', (_request, _reply, done) => {
|
|
50
|
+
als.run({ count: 0 }, done);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// onSend can still mutate headers (runs before the body is flushed).
|
|
54
|
+
app.addHook('onSend', async (_request, reply, payload) => {
|
|
55
|
+
const store = als.getStore();
|
|
56
|
+
if (store) reply.header('X-Query-Count', String(store.count));
|
|
57
|
+
return payload;
|
|
58
|
+
});
|
|
59
|
+
}
|