@tuturuuu/utils 0.7.0 → 0.9.0
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 +42 -0
- package/biome.json +1 -1
- package/package.json +18 -15
- package/src/__tests__/api-proxy-guard.test.ts +980 -62
- package/src/__tests__/app-url.test.ts +22 -0
- package/src/__tests__/upstash-rest.test.ts +3 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +258 -0
- package/src/abuse-protection/__tests__/index.test.ts +198 -6
- package/src/abuse-protection/backend-rate-limit.ts +5 -4
- package/src/abuse-protection/constants.ts +4 -0
- package/src/abuse-protection/edge-trust.ts +412 -0
- package/src/abuse-protection/index.ts +158 -138
- package/src/abuse-protection/reputation.ts +10 -0
- package/src/api-proxy-guard.ts +448 -27
- package/src/currencies.test.ts +33 -0
- package/src/currencies.ts +28 -5
- package/src/effect.test.ts +247 -0
- package/src/effect.ts +259 -0
- package/src/internal-domains.ts +78 -2
- package/src/money.test.ts +82 -0
- package/src/money.ts +120 -0
- package/src/next-config.test.ts +13 -0
- package/src/next-config.ts +0 -8
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-helper/task-operations.ts +3 -0
- package/src/upstash-rest.ts +3 -1
- package/src/workspace-helper.ts +28 -1
package/src/api-proxy-guard.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { Ratelimit } from '@upstash/ratelimit';
|
|
|
2
2
|
import type { NextRequest } from 'next/server';
|
|
3
3
|
import { NextResponse } from 'next/server';
|
|
4
4
|
import { extractIPFromRequest, isIPBlockedEdge } from './abuse-protection/edge';
|
|
5
|
+
import {
|
|
6
|
+
type CachedTrustEntry,
|
|
7
|
+
getCachedTrustEntries,
|
|
8
|
+
} from './abuse-protection/edge-trust';
|
|
5
9
|
import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
|
|
6
10
|
import { validateRequestEmojiLimit } from './request-emoji-limit';
|
|
7
11
|
import {
|
|
@@ -11,6 +15,45 @@ import {
|
|
|
11
15
|
|
|
12
16
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
13
17
|
|
|
18
|
+
// Quantized trust tiers — the cached multiplier is floored to one of these
|
|
19
|
+
// before scaling read limits, so the limiter prefix (and therefore the Redis
|
|
20
|
+
// sliding-window buckets) stays bounded and stable even as a subject's exact
|
|
21
|
+
// multiplier drifts.
|
|
22
|
+
const TRUST_MULTIPLIER_TIERS = [1, 1.5, 2, 3, 5, 10, 25, 100] as const;
|
|
23
|
+
|
|
24
|
+
function isEnvFlagEnabled(name: string, defaultOn = true): boolean {
|
|
25
|
+
const raw = process.env[name];
|
|
26
|
+
if (raw == null || raw.trim() === '') {
|
|
27
|
+
return defaultOn;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return !/^(0|false|off|no)$/i.test(raw.trim());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether trusted accounts/locations get scaled read limits (and verified
|
|
35
|
+
* sessions get their own per-session bucket) at the edge. Default on; set
|
|
36
|
+
* `API_PROXY_EDGE_TRUST_ENABLED=0` to disable trust uplift and fall back to
|
|
37
|
+
* legacy per-IP keying without a deploy.
|
|
38
|
+
*/
|
|
39
|
+
function edgeTrustEnabled(): boolean {
|
|
40
|
+
return isEnvFlagEnabled('API_PROXY_EDGE_TRUST_ENABLED');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Floors a raw trust multiplier to the nearest quantized tier. Never reduces
|
|
45
|
+
* below 1 — trust uplift only ever raises read limits.
|
|
46
|
+
*/
|
|
47
|
+
function quantizeTrustMultiplier(multiplier: number): number {
|
|
48
|
+
let tier = 1;
|
|
49
|
+
for (const candidate of TRUST_MULTIPLIER_TIERS) {
|
|
50
|
+
if (multiplier >= candidate) {
|
|
51
|
+
tier = candidate;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return tier;
|
|
55
|
+
}
|
|
56
|
+
|
|
14
57
|
type CallerClass = 'anonymous' | 'authenticated';
|
|
15
58
|
|
|
16
59
|
export type RateLimitWindow = 'minute' | 'hour' | 'day';
|
|
@@ -37,6 +80,7 @@ export type TrustedProxyBypassRule = {
|
|
|
37
80
|
};
|
|
38
81
|
|
|
39
82
|
export type GuardOptions = {
|
|
83
|
+
additionalRoutePolicies?: ProxyRoutePolicy[];
|
|
40
84
|
prefixBase: string;
|
|
41
85
|
routePolicies?: ProxyRoutePolicy[];
|
|
42
86
|
trustedBypassRules?: TrustedProxyBypassRule[];
|
|
@@ -143,18 +187,39 @@ const DEFAULT_ANONYMOUS_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
|
143
187
|
createConfig('day', '1 d', 600, 'API_PROXY_ANON_MUTATE_LIMIT_DAY'),
|
|
144
188
|
];
|
|
145
189
|
|
|
190
|
+
const DEFAULT_AUTHENTICATED_READ_RATE_LIMITS: RateLimitConfig[] = [
|
|
191
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_AUTH_READ_LIMIT_MINUTE'),
|
|
192
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_AUTH_READ_LIMIT_HOUR'),
|
|
193
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_AUTH_READ_LIMIT_DAY'),
|
|
194
|
+
];
|
|
195
|
+
|
|
146
196
|
const DEFAULT_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
147
197
|
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
148
198
|
{ window: 'hour', limit: 300, duration: '1 h' },
|
|
149
199
|
{ window: 'day', limit: 2000, duration: '1 d' },
|
|
150
200
|
];
|
|
151
201
|
|
|
202
|
+
const USERS_ME_READ_RATE_LIMITS: RateLimitConfig[] = [
|
|
203
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_USERS_ME_READ_LIMIT_MINUTE'),
|
|
204
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_USERS_ME_READ_LIMIT_HOUR'),
|
|
205
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_USERS_ME_READ_LIMIT_DAY'),
|
|
206
|
+
];
|
|
207
|
+
|
|
152
208
|
const USERS_ME_MUTATE_RATE_LIMITS: RateLimitConfig[] = [
|
|
153
209
|
{ window: 'minute', limit: 60, duration: '1 m' },
|
|
154
210
|
{ window: 'hour', limit: 200, duration: '1 h' },
|
|
155
211
|
{ window: 'day', limit: 1200, duration: '1 d' },
|
|
156
212
|
];
|
|
157
213
|
|
|
214
|
+
const WORKSPACE_DASHBOARD_READ_RATE_LIMITS: RateLimitProfile = {
|
|
215
|
+
get: [
|
|
216
|
+
createConfig('minute', '1 m', 600, 'API_PROXY_WORKSPACE_READ_LIMIT_MINUTE'),
|
|
217
|
+
createConfig('hour', '1 h', 6000, 'API_PROXY_WORKSPACE_READ_LIMIT_HOUR'),
|
|
218
|
+
createConfig('day', '1 d', 40_000, 'API_PROXY_WORKSPACE_READ_LIMIT_DAY'),
|
|
219
|
+
],
|
|
220
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
221
|
+
};
|
|
222
|
+
|
|
158
223
|
const AUTH_RATE_LIMITS: RateLimitProfile = {
|
|
159
224
|
get: NO_READ_RATE_LIMITS,
|
|
160
225
|
mutate: [
|
|
@@ -250,11 +315,11 @@ const TASK_BOARD_READ_RATE_LIMITS: RateLimitProfile = {
|
|
|
250
315
|
createConfig(
|
|
251
316
|
'minute',
|
|
252
317
|
'1 m',
|
|
253
|
-
|
|
318
|
+
600,
|
|
254
319
|
'API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE'
|
|
255
320
|
),
|
|
256
|
-
createConfig('hour', '1 h',
|
|
257
|
-
createConfig('day', '1 d',
|
|
321
|
+
createConfig('hour', '1 h', 12_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_HOUR'),
|
|
322
|
+
createConfig('day', '1 d', 80_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_DAY'),
|
|
258
323
|
],
|
|
259
324
|
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
260
325
|
};
|
|
@@ -268,6 +333,25 @@ const USERS_DATABASE_READ_OVER_POST_RATE_LIMITS: RateLimitProfile = {
|
|
|
268
333
|
],
|
|
269
334
|
};
|
|
270
335
|
|
|
336
|
+
const USERS_ADMIN_READ_RATE_LIMITS: RateLimitProfile = {
|
|
337
|
+
get: [
|
|
338
|
+
createConfig(
|
|
339
|
+
'minute',
|
|
340
|
+
'1 m',
|
|
341
|
+
1200,
|
|
342
|
+
'API_PROXY_USERS_ADMIN_READ_LIMIT_MINUTE'
|
|
343
|
+
),
|
|
344
|
+
createConfig(
|
|
345
|
+
'hour',
|
|
346
|
+
'1 h',
|
|
347
|
+
12_000,
|
|
348
|
+
'API_PROXY_USERS_ADMIN_READ_LIMIT_HOUR'
|
|
349
|
+
),
|
|
350
|
+
createConfig('day', '1 d', 80_000, 'API_PROXY_USERS_ADMIN_READ_LIMIT_DAY'),
|
|
351
|
+
],
|
|
352
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
353
|
+
};
|
|
354
|
+
|
|
271
355
|
const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
|
|
272
356
|
get: [
|
|
273
357
|
createConfig('minute', '1 m', 1200, 'API_PROXY_FINANCE_READ_LIMIT_MINUTE', [
|
|
@@ -318,6 +402,33 @@ function isUsersDatabaseReadOverPost(req: NextRequest) {
|
|
|
318
402
|
);
|
|
319
403
|
}
|
|
320
404
|
|
|
405
|
+
function isUsersAdminRead(req: NextRequest) {
|
|
406
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const pathname = req.nextUrl.pathname;
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
/^\/api\/v1\/workspaces\/[^/]+\/users(?:\/(?:database|groups|feedbacks|attendance\/export|[^/]+\/(?:attendance|emails|referrals|linked-promotions|referral-discounts|user-groups)))?\/?$/u.test(
|
|
414
|
+
pathname
|
|
415
|
+
) ||
|
|
416
|
+
/^\/api\/v1\/workspaces\/[^/]+\/user-groups\/[^/]+(?:\/(?:attendance|members(?:\/[^/]+(?:\/feedbacks)?)?|posts(?:\/[^/]+(?:\/status)?)?|linked-products|storage|indicators(?:\/.*)?))?\/?$/u.test(
|
|
417
|
+
pathname
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isWorkspaceDashboardRead(req: NextRequest) {
|
|
423
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return /^\/api(?:\/v1)?\/workspaces\/[^/]+(?:\/|$)/u.test(
|
|
428
|
+
req.nextUrl.pathname
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
321
432
|
const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
322
433
|
{
|
|
323
434
|
key: 'cron',
|
|
@@ -397,6 +508,16 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
|
397
508
|
matches: isFinanceInvoiceCreateSupportRead,
|
|
398
509
|
rateLimits: FINANCE_READ_RATE_LIMITS,
|
|
399
510
|
},
|
|
511
|
+
{
|
|
512
|
+
key: 'users-admin-read',
|
|
513
|
+
matches: isUsersAdminRead,
|
|
514
|
+
rateLimits: USERS_ADMIN_READ_RATE_LIMITS,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
key: 'workspace-dashboard-read',
|
|
518
|
+
matches: isWorkspaceDashboardRead,
|
|
519
|
+
rateLimits: WORKSPACE_DASHBOARD_READ_RATE_LIMITS,
|
|
520
|
+
},
|
|
400
521
|
{
|
|
401
522
|
key: 'high-fanout',
|
|
402
523
|
matches: (req) =>
|
|
@@ -423,7 +544,7 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
|
423
544
|
key: 'users-me',
|
|
424
545
|
matches: (req) => req.nextUrl.pathname.startsWith('/api/v1/users/me'),
|
|
425
546
|
rateLimits: {
|
|
426
|
-
get:
|
|
547
|
+
get: USERS_ME_READ_RATE_LIMITS,
|
|
427
548
|
mutate: USERS_ME_MUTATE_RATE_LIMITS,
|
|
428
549
|
},
|
|
429
550
|
},
|
|
@@ -766,15 +887,254 @@ export function hasAuthenticatedApiSession(req: NextRequest): boolean {
|
|
|
766
887
|
}
|
|
767
888
|
|
|
768
889
|
function getCallerClass(req: NextRequest): CallerClass {
|
|
890
|
+
// Intentionally always 'anonymous'. Auth headers/cookies are forgeable at the
|
|
891
|
+
// edge (we can't verify signatures cheaply here), so presence alone must never
|
|
892
|
+
// grant an elevated proxy budget — real per-user enforcement happens
|
|
893
|
+
// downstream. Per-session proxy buckets and higher read throughput for
|
|
894
|
+
// genuinely trusted accounts/locations are granted instead via the
|
|
895
|
+
// server-written trust cache (see resolveProxyIdentity +
|
|
896
|
+
// getCachedTrustEntries), which cannot be forged.
|
|
769
897
|
void req;
|
|
770
898
|
return 'anonymous';
|
|
771
899
|
}
|
|
772
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Edge-safe stable subject hash. MUST stay byte-identical to
|
|
903
|
+
* `reputation.ts hashStableSubject` (sha256 hex, sliced to 24 chars) so that
|
|
904
|
+
* the edge `session:<hash>` subject key matches the DB-side key used by the
|
|
905
|
+
* trust cache.
|
|
906
|
+
*/
|
|
907
|
+
async function hashStableSubjectEdge(value: string): Promise<string> {
|
|
908
|
+
const digest = await crypto.subtle.digest(
|
|
909
|
+
'SHA-256',
|
|
910
|
+
new TextEncoder().encode(value)
|
|
911
|
+
);
|
|
912
|
+
return Array.from(new Uint8Array(digest))
|
|
913
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
914
|
+
.join('')
|
|
915
|
+
.slice(0, 24);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function parseCookieHeaderEdge(
|
|
919
|
+
cookieHeader: string | null
|
|
920
|
+
): Array<{ name: string; value: string }> {
|
|
921
|
+
if (!cookieHeader) {
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return cookieHeader
|
|
926
|
+
.split(';')
|
|
927
|
+
.map((entry) => {
|
|
928
|
+
const separatorIndex = entry.indexOf('=');
|
|
929
|
+
if (separatorIndex < 0) {
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
return {
|
|
934
|
+
name: entry.slice(0, separatorIndex).trim(),
|
|
935
|
+
value: entry.slice(separatorIndex + 1).trim(),
|
|
936
|
+
};
|
|
937
|
+
})
|
|
938
|
+
.filter(
|
|
939
|
+
(entry): entry is { name: string; value: string } =>
|
|
940
|
+
!!entry?.name && !!entry.value
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function getSessionAuthCookie(
|
|
945
|
+
req: NextRequest
|
|
946
|
+
): { name: string; value: string } | null {
|
|
947
|
+
const cookies = parseCookieHeaderEdge(req.headers.get('cookie'));
|
|
948
|
+
return (
|
|
949
|
+
cookies.find(
|
|
950
|
+
(cookie) =>
|
|
951
|
+
cookie.name === 'tuturuuu_app_session' ||
|
|
952
|
+
GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(cookie.name)
|
|
953
|
+
) ?? null
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Mirrors `reputation.ts getCidrSubjectKey` so the edge cidr subject key matches
|
|
959
|
+
* the DB-side trusted-location key.
|
|
960
|
+
*/
|
|
961
|
+
function getCidrSubjectKeyEdge(ipAddress: string | null): string | null {
|
|
962
|
+
if (!ipAddress) {
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const ipv4Parts = ipAddress.split('.');
|
|
967
|
+
if (
|
|
968
|
+
ipv4Parts.length === 4 &&
|
|
969
|
+
ipv4Parts.every((part) => /^\d{1,3}$/.test(part))
|
|
970
|
+
) {
|
|
971
|
+
return `cidr:${ipv4Parts.slice(0, 3).join('.')}.0/24`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const ipv6Parts = ipAddress.split(':').filter(Boolean);
|
|
975
|
+
if (ipv6Parts.length >= 4) {
|
|
976
|
+
return `cidr:${ipv6Parts.slice(0, 4).join(':')}::/64`;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export type ProxyIdentity = {
|
|
983
|
+
/** `ip:<ip>` subject key, or null when the IP is unknown. */
|
|
984
|
+
ipKey: string | null;
|
|
985
|
+
/** `cidr:<network>` subject key for trusted-location lookups, or null. */
|
|
986
|
+
cidrKey: string | null;
|
|
987
|
+
/** `session:<hash>` subject key for cookie sessions, or null. Matches the
|
|
988
|
+
* DB-side session subject key so trusted sessions resolve from the cache. */
|
|
989
|
+
sessionKey: string | null;
|
|
990
|
+
/** `workspace:<wsId>` subject key parsed from a workspace-scoped API path, or
|
|
991
|
+
* null. Lets an admin workspace rule uplift reads for the whole workspace. */
|
|
992
|
+
workspaceKey: string | null;
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const WORKSPACE_PATH_PATTERN =
|
|
996
|
+
/^\/api(?:\/v1)?\/workspaces\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\/|$)/u;
|
|
997
|
+
|
|
998
|
+
function getWorkspaceSubjectKey(pathname: string): string | null {
|
|
999
|
+
const match = WORKSPACE_PATH_PATTERN.exec(pathname);
|
|
1000
|
+
return match ? `workspace:${match[1]!.toLowerCase()}` : null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Resolves the trust/keying subject keys for a request. Limited to
|
|
1005
|
+
* session/cidr/ip (keys an attacker would have to actually possess or share a
|
|
1006
|
+
* network with) plus a workspace key parsed from the path — so trust uplift and
|
|
1007
|
+
* per-session keying can never be granted by a forged credential.
|
|
1008
|
+
*/
|
|
1009
|
+
async function resolveProxyIdentity(
|
|
1010
|
+
req: NextRequest,
|
|
1011
|
+
ip: string
|
|
1012
|
+
): Promise<ProxyIdentity> {
|
|
1013
|
+
const normalizedIp = ip && ip !== 'unknown' ? ip : null;
|
|
1014
|
+
const sessionCookie = getSessionAuthCookie(req);
|
|
1015
|
+
const sessionKey = sessionCookie
|
|
1016
|
+
? `session:${await hashStableSubjectEdge(`${sessionCookie.name}:${sessionCookie.value}`)}`
|
|
1017
|
+
: null;
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
cidrKey: getCidrSubjectKeyEdge(normalizedIp),
|
|
1021
|
+
ipKey: normalizedIp ? `ip:${normalizedIp}` : null,
|
|
1022
|
+
sessionKey,
|
|
1023
|
+
workspaceKey: getWorkspaceSubjectKey(req.nextUrl.pathname),
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Scales the read (`get`) limits of a profile by a trust multiplier. Mutation
|
|
1029
|
+
* limits are left flat — those are already trust-scaled at the DB and in the
|
|
1030
|
+
* downstream session/API wrappers, so scaling them here would double-apply.
|
|
1031
|
+
*/
|
|
1032
|
+
function scaleReadLimits(
|
|
1033
|
+
profile: RateLimitProfile,
|
|
1034
|
+
multiplier: number
|
|
1035
|
+
): RateLimitProfile {
|
|
1036
|
+
if (multiplier <= 1) {
|
|
1037
|
+
return profile;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
...profile,
|
|
1042
|
+
get: profile.get.map((config) => ({
|
|
1043
|
+
...config,
|
|
1044
|
+
limit: Math.max(config.limit, Math.floor(config.limit * multiplier)),
|
|
1045
|
+
})),
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function entryReadFloor(abs: NonNullable<CachedTrustEntry['abs']>): number {
|
|
1050
|
+
return abs.minute ?? abs.hour ?? abs.day ?? 0;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Combines all applicable cached entries (session + location/workspace) into a
|
|
1055
|
+
* single effective read decision. Most-permissive wins: unlimited beats
|
|
1056
|
+
* absolute beats the highest multiplier.
|
|
1057
|
+
*/
|
|
1058
|
+
function combineTrustEntries(
|
|
1059
|
+
entries: Array<CachedTrustEntry | null | undefined>
|
|
1060
|
+
): CachedTrustEntry | null {
|
|
1061
|
+
const active = entries.filter((entry): entry is CachedTrustEntry => !!entry);
|
|
1062
|
+
if (active.length === 0) {
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (active.some((entry) => entry.mode === 'unlimited')) {
|
|
1067
|
+
return { m: 1, mode: 'unlimited' };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const absolutes = active.filter(
|
|
1071
|
+
(
|
|
1072
|
+
entry
|
|
1073
|
+
): entry is CachedTrustEntry & {
|
|
1074
|
+
abs: NonNullable<CachedTrustEntry['abs']>;
|
|
1075
|
+
} => entry.mode === 'absolute' && !!entry.abs
|
|
1076
|
+
);
|
|
1077
|
+
if (absolutes.length > 0) {
|
|
1078
|
+
const best = absolutes.reduce((winner, candidate) =>
|
|
1079
|
+
entryReadFloor(candidate.abs) > entryReadFloor(winner.abs)
|
|
1080
|
+
? candidate
|
|
1081
|
+
: winner
|
|
1082
|
+
);
|
|
1083
|
+
return { abs: best.abs, m: best.m, mode: 'absolute' };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return { m: Math.max(...active.map((entry) => entry.m)) };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Applies a combined trust entry to a route profile's READ limits and returns
|
|
1091
|
+
* the scaled profile plus a bucket-prefix suffix that uniquely encodes the
|
|
1092
|
+
* effective limit (so distinct limits never share a Redis sliding window).
|
|
1093
|
+
*/
|
|
1094
|
+
function applyTrustEntryToReads(
|
|
1095
|
+
profile: RateLimitProfile,
|
|
1096
|
+
entry: CachedTrustEntry | null
|
|
1097
|
+
): { profile: RateLimitProfile; suffix: string } {
|
|
1098
|
+
if (!entry) {
|
|
1099
|
+
return { profile, suffix: '' };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (entry.mode === 'unlimited') {
|
|
1103
|
+
// Drop the read cap entirely (no read limiter is constructed for [] get).
|
|
1104
|
+
return { profile: { ...profile, get: [] }, suffix: ':unl' };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (entry.mode === 'absolute' && entry.abs) {
|
|
1108
|
+
const get = profile.get.map((config) => {
|
|
1109
|
+
const absolute = entry.abs?.[config.window];
|
|
1110
|
+
return absolute != null ? { ...config, limit: absolute } : config;
|
|
1111
|
+
});
|
|
1112
|
+
const { minute, hour, day } = entry.abs;
|
|
1113
|
+
return {
|
|
1114
|
+
profile: { ...profile, get },
|
|
1115
|
+
suffix: `:abs-${minute ?? 'x'}-${hour ?? 'x'}-${day ?? 'x'}`,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const tier = quantizeTrustMultiplier(entry.m);
|
|
1120
|
+
if (tier <= 1) {
|
|
1121
|
+
return { profile, suffix: '' };
|
|
1122
|
+
}
|
|
1123
|
+
return { profile: scaleReadLimits(profile, tier), suffix: `:t${tier}` };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
773
1126
|
function getEffectiveRateLimits(
|
|
774
1127
|
routePolicy: ProxyRoutePolicy,
|
|
775
1128
|
callerClass: CallerClass
|
|
776
1129
|
): RateLimitProfile {
|
|
777
1130
|
if (callerClass === 'authenticated') {
|
|
1131
|
+
if (routePolicy.key === 'default') {
|
|
1132
|
+
return {
|
|
1133
|
+
get: DEFAULT_AUTHENTICATED_READ_RATE_LIMITS,
|
|
1134
|
+
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
778
1138
|
return routePolicy.rateLimits;
|
|
779
1139
|
}
|
|
780
1140
|
|
|
@@ -788,21 +1148,17 @@ function getEffectiveRateLimits(
|
|
|
788
1148
|
return routePolicy.rateLimits;
|
|
789
1149
|
}
|
|
790
1150
|
|
|
791
|
-
function
|
|
792
|
-
return routePolicy.key === 'default' || routePolicy.key === 'users-me';
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function getPathScopedRateLimitPrefix(
|
|
1151
|
+
function getRateLimitPrefix(
|
|
796
1152
|
prefixBase: string,
|
|
797
1153
|
routePolicy: ProxyRoutePolicy,
|
|
798
1154
|
callerClass: CallerClass,
|
|
799
|
-
|
|
1155
|
+
trustSuffix = ''
|
|
800
1156
|
): string {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
return `${prefixBase}:${routePolicy.key}:${callerClass}${
|
|
1157
|
+
// Only trusted/uplifted traffic gets a suffixed bucket; untrusted traffic
|
|
1158
|
+
// keeps the original prefix so its sliding windows are unaffected. The suffix
|
|
1159
|
+
// must uniquely encode the effective limit (tier or absolute values) so two
|
|
1160
|
+
// different effective limits never share a Redis sliding-window prefix.
|
|
1161
|
+
return `${prefixBase}:${routePolicy.key}:${callerClass}${trustSuffix}`;
|
|
806
1162
|
}
|
|
807
1163
|
|
|
808
1164
|
export function isTrustedProxyBypassRequest(
|
|
@@ -870,7 +1226,10 @@ export async function guardApiProxyRequest(
|
|
|
870
1226
|
);
|
|
871
1227
|
}
|
|
872
1228
|
|
|
873
|
-
const routePolicies =
|
|
1229
|
+
const routePolicies = [
|
|
1230
|
+
...(options.additionalRoutePolicies ?? []),
|
|
1231
|
+
...(options.routePolicies ?? DEFAULT_ROUTE_POLICIES),
|
|
1232
|
+
];
|
|
874
1233
|
const trustedBypassRules = [
|
|
875
1234
|
...DEFAULT_TRUSTED_BYPASS_RULES,
|
|
876
1235
|
...(options.trustedBypassRules ?? []),
|
|
@@ -886,8 +1245,9 @@ export async function guardApiProxyRequest(
|
|
|
886
1245
|
) {
|
|
887
1246
|
const callerClass = getCallerClass(req);
|
|
888
1247
|
const ip = extractIPFromRequest(req.headers);
|
|
1248
|
+
const identity = await resolveProxyIdentity(req, ip);
|
|
889
1249
|
|
|
890
|
-
if (ip !== 'unknown'
|
|
1250
|
+
if (ip !== 'unknown') {
|
|
891
1251
|
const blockInfo = await isIPBlockedEdge(ip);
|
|
892
1252
|
if (blockInfo) {
|
|
893
1253
|
const retryAfter = Math.max(
|
|
@@ -899,15 +1259,76 @@ export async function guardApiProxyRequest(
|
|
|
899
1259
|
'X-Proxy-Block-Reason': 'ip-already-blocked',
|
|
900
1260
|
});
|
|
901
1261
|
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
1265
|
+
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
1266
|
+
|
|
1267
|
+
// Resolve server-verified session keying for reads and mutations, but apply
|
|
1268
|
+
// trust uplift to read limits only. Account trust (session key) and location
|
|
1269
|
+
// trust (cidr/ip keys) are distinguished so that:
|
|
1270
|
+
// * a server-verified SESSION (cache entry keyed on the real cookie's
|
|
1271
|
+
// hash — unforgeable) gets its OWN per-session bucket, so signed-in
|
|
1272
|
+
// teammates behind one NAT no longer collide; and
|
|
1273
|
+
// * a trusted LOCATION (office cidr/ip) uplifts the shared per-IP bucket
|
|
1274
|
+
// for the whole team without any per-request credential.
|
|
1275
|
+
// Untrusted traffic keeps legacy per-IP keying, preserving single-IP abuse
|
|
1276
|
+
// caps. A forged cookie hashes to a key with no cache entry, so it cannot
|
|
1277
|
+
// escape the shared IP bucket.
|
|
1278
|
+
let trustEntry: CachedTrustEntry | null = null;
|
|
1279
|
+
let sessionScoped = false;
|
|
1280
|
+
if (edgeTrustEnabled()) {
|
|
1281
|
+
const lookupKeys = [
|
|
1282
|
+
identity.sessionKey,
|
|
1283
|
+
...(isRead
|
|
1284
|
+
? [identity.cidrKey, identity.ipKey, identity.workspaceKey]
|
|
1285
|
+
: []),
|
|
1286
|
+
].filter((key): key is string => key != null);
|
|
1287
|
+
const entries = await getCachedTrustEntries(lookupKeys);
|
|
1288
|
+
|
|
1289
|
+
const sessionEntry = identity.sessionKey
|
|
1290
|
+
? entries.get(identity.sessionKey)
|
|
1291
|
+
: undefined;
|
|
1292
|
+
sessionScoped = !!identity.sessionKey && !!sessionEntry;
|
|
1293
|
+
|
|
1294
|
+
if (isRead) {
|
|
1295
|
+
// Location/workspace trust applies to the shared per-IP bucket for the
|
|
1296
|
+
// whole team; session trust applies to the per-session bucket.
|
|
1297
|
+
const locationEntry = combineTrustEntries([
|
|
1298
|
+
identity.cidrKey ? entries.get(identity.cidrKey) : undefined,
|
|
1299
|
+
identity.ipKey ? entries.get(identity.ipKey) : undefined,
|
|
1300
|
+
identity.workspaceKey
|
|
1301
|
+
? entries.get(identity.workspaceKey)
|
|
1302
|
+
: undefined,
|
|
1303
|
+
]);
|
|
1304
|
+
|
|
1305
|
+
trustEntry = combineTrustEntries([sessionEntry, locationEntry]);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
902
1308
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1309
|
+
// Server-verified sessions key per-session; everyone else keys per-IP.
|
|
1310
|
+
const limiterId =
|
|
1311
|
+
sessionScoped && identity.sessionKey
|
|
1312
|
+
? identity.sessionKey
|
|
1313
|
+
: identity.ipKey;
|
|
1314
|
+
|
|
1315
|
+
if (limiterId) {
|
|
1316
|
+
const effectiveCallerClass: CallerClass = sessionScoped
|
|
1317
|
+
? 'authenticated'
|
|
1318
|
+
: callerClass;
|
|
1319
|
+
const baseRateLimits = getEffectiveRateLimits(
|
|
1320
|
+
routePolicy,
|
|
1321
|
+
effectiveCallerClass
|
|
1322
|
+
);
|
|
1323
|
+
const { profile: rateLimits, suffix: trustSuffix } = isRead
|
|
1324
|
+
? applyTrustEntryToReads(baseRateLimits, trustEntry)
|
|
1325
|
+
: { profile: baseRateLimits, suffix: '' };
|
|
1326
|
+
|
|
1327
|
+
const limiterPrefix = getRateLimitPrefix(
|
|
907
1328
|
options.prefixBase,
|
|
908
1329
|
routePolicy,
|
|
909
|
-
|
|
910
|
-
|
|
1330
|
+
effectiveCallerClass,
|
|
1331
|
+
trustSuffix
|
|
911
1332
|
);
|
|
912
1333
|
const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
|
|
913
1334
|
const limiters = await getRateLimiters(limiterPrefix, rateLimits);
|
|
@@ -918,7 +1339,7 @@ export async function guardApiProxyRequest(
|
|
|
918
1339
|
let result: RateLimitResult;
|
|
919
1340
|
|
|
920
1341
|
try {
|
|
921
|
-
result = await limiter.limit(
|
|
1342
|
+
result = await limiter.limit(limiterId);
|
|
922
1343
|
} catch {
|
|
923
1344
|
const fallbackLimiters = replaceWithLocalRateLimiters(
|
|
924
1345
|
limiterCacheKey,
|
|
@@ -936,7 +1357,7 @@ export async function guardApiProxyRequest(
|
|
|
936
1357
|
continue;
|
|
937
1358
|
}
|
|
938
1359
|
|
|
939
|
-
result = await fallbackBucket.limiter.limit(
|
|
1360
|
+
result = await fallbackBucket.limiter.limit(limiterId);
|
|
940
1361
|
}
|
|
941
1362
|
|
|
942
1363
|
const { success, limit, remaining, reset } = result;
|
|
@@ -945,7 +1366,7 @@ export async function guardApiProxyRequest(
|
|
|
945
1366
|
const consumed = limit - remaining;
|
|
946
1367
|
const kind = isRead ? 'read' : 'mutate';
|
|
947
1368
|
console.log(
|
|
948
|
-
`[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} |
|
|
1369
|
+
`[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | id: ${limiterId} | path: ${req.nextUrl.pathname}`
|
|
949
1370
|
);
|
|
950
1371
|
}
|
|
951
1372
|
|
|
@@ -960,7 +1381,7 @@ export async function guardApiProxyRequest(
|
|
|
960
1381
|
'X-RateLimit-Limit': `${limit}`,
|
|
961
1382
|
'X-RateLimit-Remaining': `${remaining}`,
|
|
962
1383
|
'X-RateLimit-Reset': `${Math.ceil(reset / 1000)}`,
|
|
963
|
-
'X-RateLimit-Caller-Class':
|
|
1384
|
+
'X-RateLimit-Caller-Class': effectiveCallerClass,
|
|
964
1385
|
'X-RateLimit-Window': window,
|
|
965
1386
|
'X-RateLimit-Policy': routePolicy.key,
|
|
966
1387
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getCurrencyConfig,
|
|
4
|
+
getCurrencyLocale,
|
|
5
|
+
isSupportedCurrency,
|
|
6
|
+
resolveSupportedCurrency,
|
|
7
|
+
} from './currencies';
|
|
8
|
+
|
|
9
|
+
describe('resolveSupportedCurrency', () => {
|
|
10
|
+
it('normalizes supported currency codes', () => {
|
|
11
|
+
expect(resolveSupportedCurrency(' sgd ')).toBe('SGD');
|
|
12
|
+
expect(resolveSupportedCurrency('eur')).toBe('EUR');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('uses a supported fallback for missing or unsupported values', () => {
|
|
16
|
+
expect(resolveSupportedCurrency(undefined, 'VND')).toBe('VND');
|
|
17
|
+
expect(resolveSupportedCurrency(null, 'EUR')).toBe('EUR');
|
|
18
|
+
expect(resolveSupportedCurrency('XYZ', 'SGD')).toBe('SGD');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('falls back to USD when the fallback is unsupported', () => {
|
|
22
|
+
expect(resolveSupportedCurrency('', 'XYZ')).toBe('USD');
|
|
23
|
+
expect(resolveSupportedCurrency('ZZZ', 'XYZ')).toBe('USD');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('currency lookup normalization', () => {
|
|
28
|
+
it('trims currency codes before support and locale checks', () => {
|
|
29
|
+
expect(isSupportedCurrency(' vnd ')).toBe(true);
|
|
30
|
+
expect(getCurrencyLocale(' php ')).toBe('en-PH');
|
|
31
|
+
expect(getCurrencyConfig(' eur ')?.code).toBe('EUR');
|
|
32
|
+
});
|
|
33
|
+
});
|