@tuturuuu/utils 0.0.3 → 0.6.1
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/CHANGELOG.md +313 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- package/src/index.ts +0 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mockRpc = vi.fn();
|
|
4
|
+
const mockCreateAdminClient = vi.fn(() => ({
|
|
5
|
+
rpc: mockRpc,
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('@tuturuuu/supabase/next/server', () => ({
|
|
9
|
+
createAdminClient: () => mockCreateAdminClient(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
buildAbuseRiskSubjects,
|
|
14
|
+
resolveAbuseRiskDecision,
|
|
15
|
+
} from '../reputation';
|
|
16
|
+
|
|
17
|
+
const browserHeaders = {
|
|
18
|
+
cookie: 'sb-test-auth-token=stable-session-token',
|
|
19
|
+
'user-agent':
|
|
20
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('adaptive abuse reputation', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
vi.setSystemTime(new Date('2026-05-17T00:00:00.000Z'));
|
|
28
|
+
mockRpc.mockResolvedValue({ data: [], error: null });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not earn trust from old account and browser shape alone', async () => {
|
|
32
|
+
const decision = await resolveAbuseRiskDecision({
|
|
33
|
+
authKind: 'session',
|
|
34
|
+
headers: browserHeaders,
|
|
35
|
+
ipAddress: '203.0.113.10',
|
|
36
|
+
isRead: true,
|
|
37
|
+
method: 'GET',
|
|
38
|
+
route: '/api/v1/workspaces/ws-1/tasks',
|
|
39
|
+
userCreatedAt: '2025-01-01T00:00:00.000Z',
|
|
40
|
+
userId: 'user-1',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(decision.tier).toBe('standard');
|
|
44
|
+
expect(decision.trustMultiplier).toBe(1);
|
|
45
|
+
expect(decision.reasons).toContain('established_account');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('allows clean long-lived reputation to receive trusted limits', async () => {
|
|
49
|
+
mockRpc.mockResolvedValue({
|
|
50
|
+
data: [
|
|
51
|
+
{
|
|
52
|
+
decision_source: 'reputation',
|
|
53
|
+
subject_key: 'user:user-1',
|
|
54
|
+
tier: 'trusted',
|
|
55
|
+
trust_multiplier: 3,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
error: null,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const decision = await resolveAbuseRiskDecision({
|
|
62
|
+
authKind: 'session',
|
|
63
|
+
headers: browserHeaders,
|
|
64
|
+
ipAddress: '203.0.113.10',
|
|
65
|
+
isRead: true,
|
|
66
|
+
method: 'GET',
|
|
67
|
+
route: '/api/v1/workspaces/ws-1/tasks',
|
|
68
|
+
userCreatedAt: '2025-01-01T00:00:00.000Z',
|
|
69
|
+
userId: 'user-1',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(decision.tier).toBe('trusted');
|
|
73
|
+
expect(decision.trustMultiplier).toBe(3);
|
|
74
|
+
expect(decision.reasons).toContain('server_reputation_trusted');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('suppresses trusted reputation when current browser mutation is scripted', async () => {
|
|
78
|
+
mockRpc.mockResolvedValue({
|
|
79
|
+
data: [
|
|
80
|
+
{
|
|
81
|
+
decision_source: 'reputation',
|
|
82
|
+
subject_key: 'user:user-1',
|
|
83
|
+
tier: 'trusted',
|
|
84
|
+
trust_multiplier: 3,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
error: null,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const decision = await resolveAbuseRiskDecision({
|
|
91
|
+
authKind: 'session',
|
|
92
|
+
headers: {
|
|
93
|
+
'user-agent': 'curl/8.7.1',
|
|
94
|
+
},
|
|
95
|
+
ipAddress: '203.0.113.10',
|
|
96
|
+
isRead: false,
|
|
97
|
+
method: 'POST',
|
|
98
|
+
route: '/api/v1/workspaces/ws-1/tasks',
|
|
99
|
+
userCreatedAt: '2025-01-01T00:00:00.000Z',
|
|
100
|
+
userId: 'user-1',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(decision.tier).toBe('challenge_required');
|
|
104
|
+
expect(decision.trustMultiplier).toBe(1);
|
|
105
|
+
expect(decision.reasons).toContain('scripted_http_client');
|
|
106
|
+
expect(decision.reasons).toContain('suspicious_browser_mutation');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('keeps recent abuse restrictions stronger than account age', async () => {
|
|
110
|
+
mockRpc.mockResolvedValue({
|
|
111
|
+
data: [
|
|
112
|
+
{
|
|
113
|
+
decision_source: 'reputation',
|
|
114
|
+
subject_key: 'user:user-1',
|
|
115
|
+
tier: 'restricted',
|
|
116
|
+
trust_multiplier: 0.35,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
error: null,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const decision = await resolveAbuseRiskDecision({
|
|
123
|
+
authKind: 'session',
|
|
124
|
+
headers: browserHeaders,
|
|
125
|
+
ipAddress: '203.0.113.10',
|
|
126
|
+
isRead: true,
|
|
127
|
+
method: 'GET',
|
|
128
|
+
route: '/api/v1/workspaces/ws-1/tasks',
|
|
129
|
+
userCreatedAt: '2025-01-01T00:00:00.000Z',
|
|
130
|
+
userId: 'user-1',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(decision.tier).toBe('restricted');
|
|
134
|
+
expect(decision.trustMultiplier).toBe(0.35);
|
|
135
|
+
expect(decision.reasons).toContain('server_reputation_restricted');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('builds API-key reputation subjects without browser challenge semantics', async () => {
|
|
139
|
+
mockRpc.mockResolvedValue({
|
|
140
|
+
data: [
|
|
141
|
+
{
|
|
142
|
+
decision_source: 'reputation',
|
|
143
|
+
subject_key: 'api-key:key-1',
|
|
144
|
+
tier: 'trusted',
|
|
145
|
+
trust_multiplier: 2,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
error: null,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const decision = await resolveAbuseRiskDecision({
|
|
152
|
+
apiKeyId: 'key-1',
|
|
153
|
+
authKind: 'api-key',
|
|
154
|
+
headers: {
|
|
155
|
+
'user-agent': 'Dart/3.9 (dart:io)',
|
|
156
|
+
},
|
|
157
|
+
ipAddress: '203.0.113.10',
|
|
158
|
+
isRead: false,
|
|
159
|
+
method: 'POST',
|
|
160
|
+
route: '/api/v1/workspaces/ws-1/tasks',
|
|
161
|
+
workspaceId: 'ws-1',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(decision.tier).toBe('trusted');
|
|
165
|
+
expect(decision.trustMultiplier).toBe(2);
|
|
166
|
+
expect(decision.subjects).toContainEqual({
|
|
167
|
+
subject_key: 'api-key:key-1',
|
|
168
|
+
subject_type: 'api_key',
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('tracks user, session, IP, CIDR, and user-location subjects together', () => {
|
|
173
|
+
expect(
|
|
174
|
+
buildAbuseRiskSubjects({
|
|
175
|
+
headers: browserHeaders,
|
|
176
|
+
ipAddress: '203.0.113.10',
|
|
177
|
+
userId: 'user-1',
|
|
178
|
+
})
|
|
179
|
+
).toEqual(
|
|
180
|
+
expect.arrayContaining([
|
|
181
|
+
{ subject_key: 'user:user-1', subject_type: 'user' },
|
|
182
|
+
{ subject_key: 'ip:203.0.113.10', subject_type: 'ip' },
|
|
183
|
+
{ subject_key: 'cidr:203.0.113.0/24', subject_type: 'cidr' },
|
|
184
|
+
{
|
|
185
|
+
subject_key: 'user-location:user-1:203.0.113.10',
|
|
186
|
+
subject_type: 'user_location',
|
|
187
|
+
},
|
|
188
|
+
expect.objectContaining({ subject_type: 'session' }),
|
|
189
|
+
])
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { blockIPEdge } from './edge';
|
|
2
|
+
import type { BlockInfo } from './types';
|
|
3
|
+
|
|
4
|
+
type BackendRateLimitSource = 'auth' | 'database';
|
|
5
|
+
|
|
6
|
+
interface BackendRateLimitEscalationOptions {
|
|
7
|
+
endpoint?: string;
|
|
8
|
+
ipAddress?: string | null;
|
|
9
|
+
source: BackendRateLimitSource;
|
|
10
|
+
/**
|
|
11
|
+
* Deprecated/no-op. Backend 429s are shared availability signals, not enough
|
|
12
|
+
* evidence to create or extend a user suspension.
|
|
13
|
+
*/
|
|
14
|
+
userId?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface BackendRateLimitErrorLike {
|
|
18
|
+
code?: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
status?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isBackendRateLimitError(
|
|
24
|
+
error: unknown
|
|
25
|
+
): error is BackendRateLimitErrorLike {
|
|
26
|
+
if (!error || typeof error !== 'object') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const candidate = error as BackendRateLimitErrorLike;
|
|
31
|
+
return (
|
|
32
|
+
candidate.status === 429 ||
|
|
33
|
+
candidate.code === 'over_request_rate_limit' ||
|
|
34
|
+
(typeof candidate.message === 'string' &&
|
|
35
|
+
/request rate limit reached/i.test(candidate.message))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function cascadeBackendRateLimitToProxyBan({
|
|
40
|
+
ipAddress,
|
|
41
|
+
}: BackendRateLimitEscalationOptions): Promise<BlockInfo | null> {
|
|
42
|
+
const shouldBlockIp = ipAddress && ipAddress !== 'unknown';
|
|
43
|
+
return shouldBlockIp ? blockIPEdge(ipAddress, 'api_abuse') : null;
|
|
44
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration constants for OTP Abuse Protection System
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rate limiting thresholds for different operations
|
|
7
|
+
*/
|
|
8
|
+
export const ABUSE_THRESHOLDS = {
|
|
9
|
+
// OTP Send limits
|
|
10
|
+
OTP_SEND_PER_MINUTE: 30,
|
|
11
|
+
OTP_SEND_PER_HOUR: 180,
|
|
12
|
+
OTP_SEND_PER_DAY: 300,
|
|
13
|
+
OTP_SEND_EMAIL_COOLDOWN_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
|
|
14
|
+
OTP_SEND_EMAIL_PER_HOUR: 2,
|
|
15
|
+
OTP_SEND_EMAIL_PER_DAY: 4,
|
|
16
|
+
|
|
17
|
+
// OTP Verify limits (per IP)
|
|
18
|
+
OTP_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
|
|
19
|
+
OTP_VERIFY_FAILED_MAX: 5,
|
|
20
|
+
|
|
21
|
+
// OTP Verify limits (per email - distributed attack protection)
|
|
22
|
+
OTP_VERIFY_FAILED_EMAIL_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
|
|
23
|
+
OTP_VERIFY_FAILED_EMAIL_MAX: 10,
|
|
24
|
+
|
|
25
|
+
// MFA limits
|
|
26
|
+
MFA_CHALLENGE_PER_MINUTE: 5,
|
|
27
|
+
MFA_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
|
|
28
|
+
MFA_VERIFY_FAILED_MAX: 5,
|
|
29
|
+
|
|
30
|
+
// Reauth limits
|
|
31
|
+
REAUTH_SEND_PER_MINUTE: 3,
|
|
32
|
+
REAUTH_VERIFY_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
|
|
33
|
+
REAUTH_VERIFY_FAILED_MAX: 5,
|
|
34
|
+
|
|
35
|
+
// Password login limits
|
|
36
|
+
PASSWORD_LOGIN_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
|
|
37
|
+
PASSWORD_LOGIN_FAILED_MAX: 10,
|
|
38
|
+
|
|
39
|
+
// API auth failure limits (session-auth routes)
|
|
40
|
+
API_AUTH_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
|
|
41
|
+
API_AUTH_FAILED_MAX: 20, // 20 failed auths in 5 min → auto-block IP
|
|
42
|
+
|
|
43
|
+
// Malformed session cookie abuse at the proxy/edge layer
|
|
44
|
+
MALFORMED_AUTH_COOKIE_WINDOW_MS: 60 * 1000, // 1 minute
|
|
45
|
+
MALFORMED_AUTH_COOKIE_MAX: 3, // 3 malformed-cookie hits in 1 min → block IP
|
|
46
|
+
|
|
47
|
+
// Generic suspicious anonymous API traffic at the proxy/edge layer
|
|
48
|
+
SUSPICIOUS_API_WINDOW_MS: 60 * 1000, // 1 minute
|
|
49
|
+
SUSPICIOUS_API_MAX: 3, // 3 suspicious proxy hits in 1 min → block IP
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Progressive block durations in seconds
|
|
54
|
+
* Level increases with repeated offenses within 24 hours
|
|
55
|
+
*/
|
|
56
|
+
export const BLOCK_DURATIONS: Record<1 | 2 | 3 | 4, number> = {
|
|
57
|
+
1: 5 * 60, // Level 1: 5 minutes
|
|
58
|
+
2: 15 * 60, // Level 2: 15 minutes
|
|
59
|
+
3: 60 * 60, // Level 3: 1 hour
|
|
60
|
+
4: 24 * 60 * 60, // Level 4: 24 hours
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Maximum block level
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_BLOCK_LEVEL = 4;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Redis key prefixes for rate limiting
|
|
70
|
+
*/
|
|
71
|
+
export const REDIS_KEYS = {
|
|
72
|
+
// OTP Send attempts per IP (sliding window)
|
|
73
|
+
OTP_SEND: (ip: string) => `otp:send:${ip}`,
|
|
74
|
+
OTP_SEND_HOURLY: (ip: string) => `otp:send:hourly:${ip}`,
|
|
75
|
+
OTP_SEND_DAILY: (ip: string) => `otp:send:daily:${ip}`,
|
|
76
|
+
OTP_SEND_EMAIL_COOLDOWN: (emailHash: string) =>
|
|
77
|
+
`otp:send:email:cooldown:${emailHash}`,
|
|
78
|
+
OTP_SEND_EMAIL_HOURLY: (emailHash: string) =>
|
|
79
|
+
`otp:send:email:hourly:${emailHash}`,
|
|
80
|
+
OTP_SEND_EMAIL_DAILY: (emailHash: string) =>
|
|
81
|
+
`otp:send:email:daily:${emailHash}`,
|
|
82
|
+
|
|
83
|
+
// OTP Verify failed attempts
|
|
84
|
+
OTP_VERIFY_FAILED: (ip: string) => `otp:verify:failed:${ip}`,
|
|
85
|
+
OTP_VERIFY_FAILED_EMAIL: (emailHash: string) =>
|
|
86
|
+
`otp:verify:failed:email:${emailHash}`,
|
|
87
|
+
|
|
88
|
+
// MFA attempts
|
|
89
|
+
MFA_CHALLENGE: (ip: string) => `mfa:challenge:${ip}`,
|
|
90
|
+
MFA_VERIFY_FAILED: (ip: string) => `mfa:verify:failed:${ip}`,
|
|
91
|
+
|
|
92
|
+
// Reauth attempts
|
|
93
|
+
REAUTH_SEND: (ip: string) => `reauth:send:${ip}`,
|
|
94
|
+
REAUTH_VERIFY_FAILED: (ip: string) => `reauth:verify:failed:${ip}`,
|
|
95
|
+
|
|
96
|
+
// Password login attempts
|
|
97
|
+
PASSWORD_LOGIN_FAILED: (ip: string) => `password:login:failed:${ip}`,
|
|
98
|
+
|
|
99
|
+
// API auth failure attempts (session-auth routes)
|
|
100
|
+
API_AUTH_FAILED: (ip: string) => `api:auth:failed:${ip}`,
|
|
101
|
+
API_MALFORMED_AUTH_COOKIE: (ip: string) => `api:auth:malformed-cookie:${ip}`,
|
|
102
|
+
API_SUSPICIOUS: (ip: string) => `api:suspicious:${ip}`,
|
|
103
|
+
|
|
104
|
+
// IP block cache
|
|
105
|
+
IP_BLOCKED: (ip: string) => `ip:blocked:${ip}`,
|
|
106
|
+
IP_BLOCK_LEVEL: (ip: string) => `ip:block:level:${ip}`,
|
|
107
|
+
} as const;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Window durations in milliseconds for different operations
|
|
111
|
+
*/
|
|
112
|
+
export const WINDOW_MS = {
|
|
113
|
+
ONE_MINUTE: 60 * 1000,
|
|
114
|
+
TEN_MINUTES: 10 * 60 * 1000,
|
|
115
|
+
ONE_HOUR: 60 * 60 * 1000,
|
|
116
|
+
TWENTY_FOUR_HOURS: 24 * 60 * 60 * 1000,
|
|
117
|
+
} as const;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge-compatible abuse protection utilities.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the main index.ts (which uses node:crypto), this module only
|
|
5
|
+
* relies on the @upstash/redis REST client and is safe to run in
|
|
6
|
+
* Vercel Edge Runtime or Next.js proxy/middleware.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
getUpstashRestRedisClient,
|
|
11
|
+
type UpstashRestRedisClient,
|
|
12
|
+
} from '../upstash-rest';
|
|
13
|
+
import {
|
|
14
|
+
ABUSE_THRESHOLDS,
|
|
15
|
+
BLOCK_DURATIONS,
|
|
16
|
+
REDIS_KEYS,
|
|
17
|
+
WINDOW_MS,
|
|
18
|
+
} from './constants';
|
|
19
|
+
import type { BlockInfo } from './types';
|
|
20
|
+
|
|
21
|
+
let edgeRedisClient: UpstashRestRedisClient | null = null;
|
|
22
|
+
let edgeRedisInitialized = false;
|
|
23
|
+
|
|
24
|
+
function parsePositiveIntEnv(name: string, fallback: number): number {
|
|
25
|
+
const rawValue = process.env[name];
|
|
26
|
+
if (!rawValue) {
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parsed = Number.parseInt(rawValue, 10);
|
|
31
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function getEdgeRedisClient() {
|
|
35
|
+
if (edgeRedisInitialized) return edgeRedisClient;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
edgeRedisClient = await getUpstashRestRedisClient();
|
|
39
|
+
edgeRedisInitialized = true;
|
|
40
|
+
return edgeRedisClient;
|
|
41
|
+
} catch {
|
|
42
|
+
edgeRedisInitialized = true;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if an IP is blocked using Redis cache only (no DB fallback).
|
|
49
|
+
* Designed for Edge Runtime where speed > completeness.
|
|
50
|
+
* The serverless layer provides full DB-backed check as backup.
|
|
51
|
+
*/
|
|
52
|
+
export async function isIPBlockedEdge(
|
|
53
|
+
ipAddress: string
|
|
54
|
+
): Promise<BlockInfo | null> {
|
|
55
|
+
try {
|
|
56
|
+
const redis = await getEdgeRedisClient();
|
|
57
|
+
if (!redis) return null;
|
|
58
|
+
|
|
59
|
+
const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
|
|
60
|
+
if (!cached) return null;
|
|
61
|
+
|
|
62
|
+
const blockInfo = typeof cached === 'string' ? JSON.parse(cached) : cached;
|
|
63
|
+
if (new Date(blockInfo.expiresAt) <= new Date()) return null;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id: blockInfo.id,
|
|
67
|
+
blockLevel: blockInfo.level,
|
|
68
|
+
reason: blockInfo.reason,
|
|
69
|
+
expiresAt: new Date(blockInfo.expiresAt),
|
|
70
|
+
blockedAt: new Date(blockInfo.blockedAt),
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
// Fail-open: if Redis is unavailable, allow request through
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function blockIPEdge(
|
|
79
|
+
ipAddress: string,
|
|
80
|
+
reason: BlockInfo['reason']
|
|
81
|
+
): Promise<BlockInfo | null> {
|
|
82
|
+
try {
|
|
83
|
+
const redis = await getEdgeRedisClient();
|
|
84
|
+
if (!redis) return null;
|
|
85
|
+
|
|
86
|
+
const currentLevel =
|
|
87
|
+
(await redis.get<number>(REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress))) || 0;
|
|
88
|
+
const newLevel = Math.min(currentLevel + 1, 4) as 1 | 2 | 3 | 4;
|
|
89
|
+
const blockDuration = BLOCK_DURATIONS[newLevel];
|
|
90
|
+
const blockedAt = new Date();
|
|
91
|
+
const expiresAt = new Date(blockedAt.getTime() + blockDuration * 1000);
|
|
92
|
+
const blockInfo = {
|
|
93
|
+
id: `edge:${ipAddress}:${blockedAt.getTime()}`,
|
|
94
|
+
blockLevel: newLevel,
|
|
95
|
+
reason,
|
|
96
|
+
blockedAt,
|
|
97
|
+
expiresAt,
|
|
98
|
+
} satisfies BlockInfo;
|
|
99
|
+
|
|
100
|
+
await Promise.all([
|
|
101
|
+
redis.set(
|
|
102
|
+
REDIS_KEYS.IP_BLOCKED(ipAddress),
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
id: blockInfo.id,
|
|
105
|
+
level: blockInfo.blockLevel,
|
|
106
|
+
reason: blockInfo.reason,
|
|
107
|
+
expiresAt: blockInfo.expiresAt.toISOString(),
|
|
108
|
+
blockedAt: blockInfo.blockedAt.toISOString(),
|
|
109
|
+
}),
|
|
110
|
+
{ ex: blockDuration }
|
|
111
|
+
),
|
|
112
|
+
redis.set(REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress), newLevel, {
|
|
113
|
+
ex: WINDOW_MS.TWENTY_FOUR_HOURS / 1000,
|
|
114
|
+
}),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
return blockInfo;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function recordEdgeAbuseSignal(
|
|
124
|
+
ipAddress: string,
|
|
125
|
+
redisKey: string,
|
|
126
|
+
{
|
|
127
|
+
maxAttempts,
|
|
128
|
+
windowMs,
|
|
129
|
+
}: {
|
|
130
|
+
maxAttempts: number;
|
|
131
|
+
windowMs: number;
|
|
132
|
+
}
|
|
133
|
+
): Promise<BlockInfo | null> {
|
|
134
|
+
try {
|
|
135
|
+
const redis = await getEdgeRedisClient();
|
|
136
|
+
if (!redis) return null;
|
|
137
|
+
|
|
138
|
+
const attempts = await redis.incr(redisKey);
|
|
139
|
+
|
|
140
|
+
if (attempts === 1) {
|
|
141
|
+
await redis.expire(redisKey, Math.ceil(windowMs / 1000));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (attempts < maxAttempts) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return blockIPEdge(ipAddress, 'api_abuse');
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function recordMalformedAuthCookieEdge(
|
|
155
|
+
ipAddress: string
|
|
156
|
+
): Promise<BlockInfo | null> {
|
|
157
|
+
return recordEdgeAbuseSignal(
|
|
158
|
+
ipAddress,
|
|
159
|
+
REDIS_KEYS.API_MALFORMED_AUTH_COOKIE(ipAddress),
|
|
160
|
+
{
|
|
161
|
+
maxAttempts: parsePositiveIntEnv(
|
|
162
|
+
'EDGE_MALFORMED_AUTH_COOKIE_MAX',
|
|
163
|
+
ABUSE_THRESHOLDS.MALFORMED_AUTH_COOKIE_MAX
|
|
164
|
+
),
|
|
165
|
+
windowMs: parsePositiveIntEnv(
|
|
166
|
+
'EDGE_MALFORMED_AUTH_COOKIE_WINDOW_MS',
|
|
167
|
+
ABUSE_THRESHOLDS.MALFORMED_AUTH_COOKIE_WINDOW_MS
|
|
168
|
+
),
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function recordSuspiciousApiRequestEdge(
|
|
174
|
+
ipAddress: string
|
|
175
|
+
): Promise<BlockInfo | null> {
|
|
176
|
+
return recordEdgeAbuseSignal(
|
|
177
|
+
ipAddress,
|
|
178
|
+
REDIS_KEYS.API_SUSPICIOUS(ipAddress),
|
|
179
|
+
{
|
|
180
|
+
maxAttempts: parsePositiveIntEnv(
|
|
181
|
+
'EDGE_SUSPICIOUS_API_MAX',
|
|
182
|
+
ABUSE_THRESHOLDS.SUSPICIOUS_API_MAX
|
|
183
|
+
),
|
|
184
|
+
windowMs: parsePositiveIntEnv(
|
|
185
|
+
'EDGE_SUSPICIOUS_API_WINDOW_MS',
|
|
186
|
+
ABUSE_THRESHOLDS.SUSPICIOUS_API_WINDOW_MS
|
|
187
|
+
),
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Lightweight IP extraction for Edge Runtime.
|
|
194
|
+
* Checks standard proxy headers in priority order.
|
|
195
|
+
*/
|
|
196
|
+
export function extractIPFromRequest(headers: Headers): string {
|
|
197
|
+
// cf-connecting-ip (Cloudflare)
|
|
198
|
+
const cfIP = headers.get('cf-connecting-ip');
|
|
199
|
+
if (cfIP && isValidIPEdge(cfIP)) return cfIP;
|
|
200
|
+
|
|
201
|
+
// true-client-ip (some Cloudflare/enterprise proxy setups)
|
|
202
|
+
const trueClientIP = headers.get('true-client-ip');
|
|
203
|
+
if (trueClientIP && isValidIPEdge(trueClientIP)) return trueClientIP;
|
|
204
|
+
|
|
205
|
+
// x-forwarded-for (most common generic proxy header)
|
|
206
|
+
const forwardedFor = headers.get('x-forwarded-for');
|
|
207
|
+
if (forwardedFor) {
|
|
208
|
+
const firstIP = forwardedFor.split(',')[0]?.trim();
|
|
209
|
+
if (firstIP && isValidIPEdge(firstIP)) return firstIP;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// x-real-ip (Nginx)
|
|
213
|
+
const realIP = headers.get('x-real-ip');
|
|
214
|
+
if (realIP && isValidIPEdge(realIP)) return realIP;
|
|
215
|
+
|
|
216
|
+
return 'unknown';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isValidIPEdge(ip: string): boolean {
|
|
220
|
+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
221
|
+
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
222
|
+
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
|
|
223
|
+
}
|