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.
@@ -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 type {
6
- RegisterInput,
7
- LoginInput,
8
- ForgotPasswordInput,
9
- ResetPasswordInput,
10
- } from './auth.schemas.js';
11
- import * as authService from './auth.service.js';
12
-
13
- export async function register(
14
- request: FastifyRequest<{ Body: RegisterInput }>,
15
- reply: FastifyReply,
16
- ): Promise<void> {
17
- const deviceInfo = request.headers['user-agent'];
18
- const ipAddress = request.ip;
19
-
20
- const result = await authService.register(request.body, deviceInfo, ipAddress);
21
-
22
- if (result.requiresVerification) {
23
- reply.status(201).send(
24
- successResponse('Registration successful. Please verify your account to continue.', { user: result.user }),
25
- );
26
- return;
27
- }
28
-
29
- setAuthCookies(reply, result.accessToken!, result.refreshToken!);
30
- reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
31
- }
32
-
33
- export async function login(
34
- request: FastifyRequest<{ Body: LoginInput }>,
35
- reply: FastifyReply,
36
- ): Promise<void> {
37
- const deviceInfo = request.headers['user-agent'];
38
- const ipAddress = request.ip;
39
-
40
- const result = await authService.login(request.body, deviceInfo, ipAddress);
41
-
42
- setAuthCookies(reply, result.accessToken, result.refreshToken);
43
- reply.send(successResponse('Logged in successfully', { user: result.user }));
44
- }
45
-
46
- export async function refresh(
47
- request: FastifyRequest,
48
- reply: FastifyReply,
49
- ): Promise<void> {
50
- const refreshToken = request.cookies.refresh_token;
51
- if (!refreshToken) {
52
- clearAuthCookies(reply);
53
- throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
54
- }
55
-
56
- try {
57
- const tokens = await authService.refresh(refreshToken);
58
- setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
59
- reply.send(successResponse('Token refreshed successfully', null));
60
- } catch (error) {
61
- // Session is definitively dead (refresh token revoked, user gone, inactive).
62
- // Clear all auth cookies so the browser stops replaying them — otherwise the
63
- // client keeps retrying refresh and middleware keeps redirecting, producing
64
- // an infinite loop that trips the rate limiter.
65
- if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
66
- clearAuthCookies(reply);
67
- }
68
- throw error;
69
- }
70
- }
71
-
72
- export async function logout(
73
- request: FastifyRequest,
74
- reply: FastifyReply,
75
- ): Promise<void> {
76
- const refreshToken = request.cookies.refresh_token;
77
- if (refreshToken) {
78
- await authService.logout(refreshToken);
79
- }
80
-
81
- clearAuthCookies(reply);
82
- reply.send(successResponse('Logged out successfully', null));
83
- }
84
-
85
- export async function me(
86
- request: FastifyRequest,
87
- reply: FastifyReply,
88
- ): Promise<void> {
89
- const user = await authService.getCurrentUser(request.user.userId);
90
- reply.send(successResponse('Current user retrieved', user));
91
- }
92
-
93
- export async function getSessions(
94
- request: FastifyRequest,
95
- reply: FastifyReply,
96
- ): Promise<void> {
97
- const sessions = await authService.getUserSessions(request.user.userId);
98
- reply.send(successResponse('User sessions retrieved', sessions));
99
- }
100
-
101
- export async function logoutAllSessions(
102
- request: FastifyRequest,
103
- reply: FastifyReply,
104
- ): Promise<void> {
105
- const count = await authService.logoutAllSessions(request.user.userId);
106
- clearAuthCookies(reply);
107
- reply.send(
108
- successResponse(`Successfully logged out from ${count} session(s)`, { count }),
109
- );
110
- }
111
-
112
- export async function forgotPassword(
113
- request: FastifyRequest<{ Body: ForgotPasswordInput }>,
114
- reply: FastifyReply,
115
- ): Promise<void> {
116
- await authService.forgotPassword(request.body.email);
117
- // Always return success to prevent email enumeration
118
- reply.send(successResponse('If an account exists with that email, a reset link has been sent', null));
119
- }
120
-
121
- export async function resetPassword(
122
- request: FastifyRequest<{ Body: ResetPasswordInput }>,
123
- reply: FastifyReply,
124
- ): Promise<void> {
125
- await authService.resetPassword(request.body.token, request.body.newPassword);
126
- reply.send(successResponse('Password reset successfully', null));
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
+ }