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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL/IP safety helpers shared across outbound-HTTP code paths.
|
|
3
|
+
*
|
|
4
|
+
* - `redactUrl` — strips secrets (Telegram bot tokens, query strings)
|
|
5
|
+
* from a URL before it reaches any log sink.
|
|
6
|
+
* - `isPrivateOrReservedIp`— SSRF building block: returns true for any loopback /
|
|
7
|
+
* private / link-local / ULA / reserved address so the
|
|
8
|
+
* image-fetch guard can reject internal targets.
|
|
9
|
+
*
|
|
10
|
+
* Pure, dependency-free, and side-effect-free so they are trivial to unit test.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Redact a URL so it is safe to log.
|
|
15
|
+
*
|
|
16
|
+
* Telegram embeds the bot token directly in the request path:
|
|
17
|
+
* https://api.telegram.org/bot<TOKEN>/getFile
|
|
18
|
+
* https://api.telegram.org/file/bot<TOKEN>/photos/file_1.jpg
|
|
19
|
+
* Both forms (`/bot<token>/` and `/file/bot<token>/`) are rewritten to
|
|
20
|
+
* `/bot<redacted>/`. Any query string is dropped wholesale (it can also carry
|
|
21
|
+
* media tokens / signatures). Non-Telegram URLs pass through unchanged apart
|
|
22
|
+
* from query stripping. NEVER throws — an unparseable string is returned with
|
|
23
|
+
* just the same path-level redaction applied.
|
|
24
|
+
*/
|
|
25
|
+
export function redactUrl(url: string): string {
|
|
26
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Drop the query string (and fragment) — may hold signed tokens.
|
|
31
|
+
const noQuery = url.split(/[?#]/, 1)[0];
|
|
32
|
+
|
|
33
|
+
// Rewrite Telegram bot-token path segments. The token charset is [A-Za-z0-9_-]
|
|
34
|
+
// plus the ':' separating bot-id from secret, e.g. 123456:AAH...
|
|
35
|
+
return noQuery.replace(/\/bot[A-Za-z0-9:_-]+/g, '/bot<redacted>');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when `addr` is a loopback / private / link-local / ULA / unspecified /
|
|
40
|
+
* reserved IP that an SSRF target could use to reach internal infrastructure.
|
|
41
|
+
*
|
|
42
|
+
* Covers:
|
|
43
|
+
* IPv4: 0.0.0.0, 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10 (CGNAT)
|
|
44
|
+
* IPv6: ::, ::1, fc00::/7 (ULA), fe80::/10 (link-local)
|
|
45
|
+
* IPv4-mapped IPv6 (::ffff:a.b.c.d) — re-checked against the IPv4 rules.
|
|
46
|
+
*
|
|
47
|
+
* Unrecognised / unparseable input returns true (fail-closed) — the caller
|
|
48
|
+
* uses this purely to BLOCK, so an address it cannot classify is treated as
|
|
49
|
+
* unsafe rather than silently allowed.
|
|
50
|
+
*/
|
|
51
|
+
export function isPrivateOrReservedIp(addr: string): boolean {
|
|
52
|
+
if (typeof addr !== 'string' || addr.length === 0) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ip = addr.trim().toLowerCase();
|
|
57
|
+
|
|
58
|
+
// IPv4-mapped IPv6, e.g. ::ffff:10.0.0.5 or ::ffff:0a00:0005 — extract the v4 tail.
|
|
59
|
+
const mapped = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
60
|
+
if (mapped) {
|
|
61
|
+
return isPrivateOrReservedIpv4(mapped[1]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (ip.includes(':')) {
|
|
65
|
+
return isReservedIpv6(ip);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return isPrivateOrReservedIpv4(ip);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isPrivateOrReservedIpv4(ip: string): boolean {
|
|
72
|
+
const parts = ip.split('.');
|
|
73
|
+
if (parts.length !== 4) {
|
|
74
|
+
return true; // not a clean dotted-quad → block
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const octets = parts.map((p) => Number(p));
|
|
78
|
+
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
|
|
79
|
+
return true; // malformed → block
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [a, b] = octets;
|
|
83
|
+
|
|
84
|
+
if (a === 0) return true; // 0.0.0.0/8 (incl. unspecified)
|
|
85
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
86
|
+
if (a === 10) return true; // 10.0.0.0/8 private
|
|
87
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
|
88
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
|
89
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
|
90
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isReservedIpv6(ip: string): boolean {
|
|
96
|
+
// Normalise: drop zone id (fe80::1%eth0) and any brackets.
|
|
97
|
+
const bare = ip.replace(/^\[|\]$/g, '').split('%')[0];
|
|
98
|
+
|
|
99
|
+
if (bare === '::' || bare === '::0' || bare === '0:0:0:0:0:0:0:0') return true; // unspecified
|
|
100
|
+
if (bare === '::1') return true; // loopback
|
|
101
|
+
|
|
102
|
+
// First hextet determines ULA / link-local ranges.
|
|
103
|
+
const firstHextet = bare.split(':')[0];
|
|
104
|
+
if (firstHextet === '') {
|
|
105
|
+
// Address starts with '::' (e.g. '::abcd') — already handled the special
|
|
106
|
+
// cases above; remaining such addresses are not in fc00::/7 or fe80::/10.
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const value = parseInt(firstHextet, 16);
|
|
111
|
+
if (Number.isNaN(value)) {
|
|
112
|
+
return true; // unparseable hextet → block
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// fc00::/7 → first 7 bits = 1111110 → first byte 0xfc or 0xfd
|
|
116
|
+
if ((value & 0xfe00) === 0xfc00) return true;
|
|
117
|
+
// fe80::/10 → first 10 bits = 1111111010 → 0xfe80..0xfebf
|
|
118
|
+
if ((value & 0xffc0) === 0xfe80) return true;
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
@@ -1,127 +1,128 @@
|
|
|
1
|
-
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
-
import { successResponse } from '@shared/responses/successResponse.js';
|
|
3
|
-
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
4
|
-
import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
reply
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
reply.
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
reply.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
reply
|
|
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
|
-
reply
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { successResponse } from '@shared/responses/successResponse.js';
|
|
3
|
+
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
4
|
+
import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
|
|
5
|
+
import { getClientIp } from '@libs/client-ip.js';
|
|
6
|
+
import type {
|
|
7
|
+
RegisterInput,
|
|
8
|
+
LoginInput,
|
|
9
|
+
ForgotPasswordInput,
|
|
10
|
+
ResetPasswordInput,
|
|
11
|
+
} from './auth.schemas.js';
|
|
12
|
+
import * as authService from './auth.service.js';
|
|
13
|
+
|
|
14
|
+
export async function register(
|
|
15
|
+
request: FastifyRequest<{ Body: RegisterInput }>,
|
|
16
|
+
reply: FastifyReply,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const deviceInfo = request.headers['user-agent'];
|
|
19
|
+
const ipAddress = getClientIp(request);
|
|
20
|
+
|
|
21
|
+
const result = await authService.register(request.body, deviceInfo, ipAddress);
|
|
22
|
+
|
|
23
|
+
if (result.requiresVerification) {
|
|
24
|
+
reply.status(201).send(
|
|
25
|
+
successResponse('Registration successful. Please verify your account to continue.', { user: result.user }),
|
|
26
|
+
);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setAuthCookies(reply, result.accessToken!, result.refreshToken!);
|
|
31
|
+
reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function login(
|
|
35
|
+
request: FastifyRequest<{ Body: LoginInput }>,
|
|
36
|
+
reply: FastifyReply,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const deviceInfo = request.headers['user-agent'];
|
|
39
|
+
const ipAddress = getClientIp(request);
|
|
40
|
+
|
|
41
|
+
const result = await authService.login(request.body, deviceInfo, ipAddress);
|
|
42
|
+
|
|
43
|
+
setAuthCookies(reply, result.accessToken, result.refreshToken);
|
|
44
|
+
reply.send(successResponse('Logged in successfully', { user: result.user }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function refresh(
|
|
48
|
+
request: FastifyRequest,
|
|
49
|
+
reply: FastifyReply,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const refreshToken = request.cookies.refresh_token;
|
|
52
|
+
if (!refreshToken) {
|
|
53
|
+
clearAuthCookies(reply);
|
|
54
|
+
throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const tokens = await authService.refresh(refreshToken);
|
|
59
|
+
setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
|
|
60
|
+
reply.send(successResponse('Token refreshed successfully', null));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Session is definitively dead (refresh token revoked, user gone, inactive).
|
|
63
|
+
// Clear all auth cookies so the browser stops replaying them — otherwise the
|
|
64
|
+
// client keeps retrying refresh and middleware keeps redirecting, producing
|
|
65
|
+
// an infinite loop that trips the rate limiter.
|
|
66
|
+
if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
|
|
67
|
+
clearAuthCookies(reply);
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function logout(
|
|
74
|
+
request: FastifyRequest,
|
|
75
|
+
reply: FastifyReply,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const refreshToken = request.cookies.refresh_token;
|
|
78
|
+
if (refreshToken) {
|
|
79
|
+
await authService.logout(refreshToken);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
clearAuthCookies(reply);
|
|
83
|
+
reply.send(successResponse('Logged out successfully', null));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function me(
|
|
87
|
+
request: FastifyRequest,
|
|
88
|
+
reply: FastifyReply,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const user = await authService.getCurrentUser(request.user.userId);
|
|
91
|
+
reply.send(successResponse('Current user retrieved', user));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function getSessions(
|
|
95
|
+
request: FastifyRequest,
|
|
96
|
+
reply: FastifyReply,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const sessions = await authService.getUserSessions(request.user.userId);
|
|
99
|
+
reply.send(successResponse('User sessions retrieved', sessions));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function logoutAllSessions(
|
|
103
|
+
request: FastifyRequest,
|
|
104
|
+
reply: FastifyReply,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const count = await authService.logoutAllSessions(request.user.userId);
|
|
107
|
+
clearAuthCookies(reply);
|
|
108
|
+
reply.send(
|
|
109
|
+
successResponse(`Successfully logged out from ${count} session(s)`, { count }),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function forgotPassword(
|
|
114
|
+
request: FastifyRequest<{ Body: ForgotPasswordInput }>,
|
|
115
|
+
reply: FastifyReply,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
await authService.forgotPassword(request.body.email);
|
|
118
|
+
// Always return success to prevent email enumeration
|
|
119
|
+
reply.send(successResponse('If an account exists with that email, a reset link has been sent', null));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function resetPassword(
|
|
123
|
+
request: FastifyRequest<{ Body: ResetPasswordInput }>,
|
|
124
|
+
reply: FastifyReply,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await authService.resetPassword(request.body.token, request.body.newPassword);
|
|
127
|
+
reply.send(successResponse('Password reset successfully', null));
|
|
128
|
+
}
|