@tuturuuu/utils 0.6.1 → 0.8.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 +41 -0
- package/biome.json +1 -1
- package/package.json +5 -4
- package/src/__tests__/api-proxy-guard.test.ts +503 -12
- package/src/__tests__/app-url.test.ts +20 -0
- package/src/__tests__/upstash-rest.test.ts +3 -0
- package/src/abuse-protection/__tests__/edge-trust.test.ts +200 -0
- package/src/abuse-protection/__tests__/index.test.ts +64 -8
- package/src/abuse-protection/constants.ts +4 -0
- package/src/abuse-protection/edge-trust.ts +344 -0
- package/src/abuse-protection/index.ts +85 -98
- package/src/abuse-protection/reputation.ts +10 -0
- package/src/api-proxy-guard.ts +371 -24
- package/src/internal-domains.ts +54 -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/permissions.tsx +19 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- 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 trusted
|
|
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 read limiting 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[];
|
|
@@ -250,15 +294,24 @@ const TASK_BOARD_READ_RATE_LIMITS: RateLimitProfile = {
|
|
|
250
294
|
createConfig(
|
|
251
295
|
'minute',
|
|
252
296
|
'1 m',
|
|
253
|
-
|
|
297
|
+
600,
|
|
254
298
|
'API_PROXY_TASK_BOARD_READ_LIMIT_MINUTE'
|
|
255
299
|
),
|
|
256
|
-
createConfig('hour', '1 h',
|
|
257
|
-
createConfig('day', '1 d',
|
|
300
|
+
createConfig('hour', '1 h', 12_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_HOUR'),
|
|
301
|
+
createConfig('day', '1 d', 80_000, 'API_PROXY_TASK_BOARD_READ_LIMIT_DAY'),
|
|
258
302
|
],
|
|
259
303
|
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
260
304
|
};
|
|
261
305
|
|
|
306
|
+
const USERS_DATABASE_READ_OVER_POST_RATE_LIMITS: RateLimitProfile = {
|
|
307
|
+
get: NO_READ_RATE_LIMITS,
|
|
308
|
+
mutate: [
|
|
309
|
+
{ window: 'minute', limit: 300, duration: '1 m' },
|
|
310
|
+
{ window: 'hour', limit: 3000, duration: '1 h' },
|
|
311
|
+
{ window: 'day', limit: 20_000, duration: '1 d' },
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
|
|
262
315
|
const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
|
|
263
316
|
get: [
|
|
264
317
|
createConfig('minute', '1 m', 1200, 'API_PROXY_FINANCE_READ_LIMIT_MINUTE', [
|
|
@@ -299,6 +352,16 @@ function isFinanceRead(req: NextRequest) {
|
|
|
299
352
|
);
|
|
300
353
|
}
|
|
301
354
|
|
|
355
|
+
function isUsersDatabaseReadOverPost(req: NextRequest) {
|
|
356
|
+
if (req.method !== 'POST') {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return /^\/api\/v1\/workspaces\/[^/]+\/users\/(?:database|groups\/(?:featured-counts|possible-excluded))\/?$/u.test(
|
|
361
|
+
req.nextUrl.pathname
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
302
365
|
const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
303
366
|
{
|
|
304
367
|
key: 'cron',
|
|
@@ -363,6 +426,11 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
|
363
426
|
)),
|
|
364
427
|
rateLimits: TASK_BOARD_READ_RATE_LIMITS,
|
|
365
428
|
},
|
|
429
|
+
{
|
|
430
|
+
key: 'users-database-read-over-post',
|
|
431
|
+
matches: isUsersDatabaseReadOverPost,
|
|
432
|
+
rateLimits: USERS_DATABASE_READ_OVER_POST_RATE_LIMITS,
|
|
433
|
+
},
|
|
366
434
|
{
|
|
367
435
|
key: 'finance-read',
|
|
368
436
|
matches: isFinanceRead,
|
|
@@ -742,10 +810,241 @@ export function hasAuthenticatedApiSession(req: NextRequest): boolean {
|
|
|
742
810
|
}
|
|
743
811
|
|
|
744
812
|
function getCallerClass(req: NextRequest): CallerClass {
|
|
813
|
+
// Intentionally always 'anonymous'. Auth headers/cookies are forgeable at the
|
|
814
|
+
// edge (we can't verify signatures cheaply here), so presence alone must never
|
|
815
|
+
// grant an elevated proxy budget — real per-user enforcement happens
|
|
816
|
+
// downstream. Higher read throughput for genuinely trusted accounts/locations
|
|
817
|
+
// is granted instead via the server-written trust cache (see resolveProxyIdentity
|
|
818
|
+
// + getCachedTrustMultiplier), which cannot be forged.
|
|
745
819
|
void req;
|
|
746
820
|
return 'anonymous';
|
|
747
821
|
}
|
|
748
822
|
|
|
823
|
+
/**
|
|
824
|
+
* Edge-safe stable subject hash. MUST stay byte-identical to
|
|
825
|
+
* `reputation.ts hashStableSubject` (sha256 hex, sliced to 24 chars) so that
|
|
826
|
+
* the edge `session:<hash>` subject key matches the DB-side key used by the
|
|
827
|
+
* trust cache.
|
|
828
|
+
*/
|
|
829
|
+
async function hashStableSubjectEdge(value: string): Promise<string> {
|
|
830
|
+
const digest = await crypto.subtle.digest(
|
|
831
|
+
'SHA-256',
|
|
832
|
+
new TextEncoder().encode(value)
|
|
833
|
+
);
|
|
834
|
+
return Array.from(new Uint8Array(digest))
|
|
835
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
836
|
+
.join('')
|
|
837
|
+
.slice(0, 24);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function parseCookieHeaderEdge(
|
|
841
|
+
cookieHeader: string | null
|
|
842
|
+
): Array<{ name: string; value: string }> {
|
|
843
|
+
if (!cookieHeader) {
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return cookieHeader
|
|
848
|
+
.split(';')
|
|
849
|
+
.map((entry) => {
|
|
850
|
+
const separatorIndex = entry.indexOf('=');
|
|
851
|
+
if (separatorIndex < 0) {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
name: entry.slice(0, separatorIndex).trim(),
|
|
857
|
+
value: entry.slice(separatorIndex + 1).trim(),
|
|
858
|
+
};
|
|
859
|
+
})
|
|
860
|
+
.filter(
|
|
861
|
+
(entry): entry is { name: string; value: string } =>
|
|
862
|
+
!!entry?.name && !!entry.value
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function getSessionAuthCookie(
|
|
867
|
+
req: NextRequest
|
|
868
|
+
): { name: string; value: string } | null {
|
|
869
|
+
const cookies = parseCookieHeaderEdge(req.headers.get('cookie'));
|
|
870
|
+
return (
|
|
871
|
+
cookies.find(
|
|
872
|
+
(cookie) =>
|
|
873
|
+
cookie.name === 'tuturuuu_app_session' ||
|
|
874
|
+
GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(cookie.name)
|
|
875
|
+
) ?? null
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Mirrors `reputation.ts getCidrSubjectKey` so the edge cidr subject key matches
|
|
881
|
+
* the DB-side trusted-location key.
|
|
882
|
+
*/
|
|
883
|
+
function getCidrSubjectKeyEdge(ipAddress: string | null): string | null {
|
|
884
|
+
if (!ipAddress) {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const ipv4Parts = ipAddress.split('.');
|
|
889
|
+
if (
|
|
890
|
+
ipv4Parts.length === 4 &&
|
|
891
|
+
ipv4Parts.every((part) => /^\d{1,3}$/.test(part))
|
|
892
|
+
) {
|
|
893
|
+
return `cidr:${ipv4Parts.slice(0, 3).join('.')}.0/24`;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const ipv6Parts = ipAddress.split(':').filter(Boolean);
|
|
897
|
+
if (ipv6Parts.length >= 4) {
|
|
898
|
+
return `cidr:${ipv6Parts.slice(0, 4).join(':')}::/64`;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export type ProxyIdentity = {
|
|
905
|
+
/** `ip:<ip>` subject key, or null when the IP is unknown. */
|
|
906
|
+
ipKey: string | null;
|
|
907
|
+
/** `cidr:<network>` subject key for trusted-location lookups, or null. */
|
|
908
|
+
cidrKey: string | null;
|
|
909
|
+
/** `session:<hash>` subject key for cookie sessions, or null. Matches the
|
|
910
|
+
* DB-side session subject key so trusted sessions resolve from the cache. */
|
|
911
|
+
sessionKey: string | null;
|
|
912
|
+
/** `workspace:<wsId>` subject key parsed from a workspace-scoped API path, or
|
|
913
|
+
* null. Lets an admin workspace rule uplift reads for the whole workspace. */
|
|
914
|
+
workspaceKey: string | null;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const WORKSPACE_PATH_PATTERN =
|
|
918
|
+
/^\/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;
|
|
919
|
+
|
|
920
|
+
function getWorkspaceSubjectKey(pathname: string): string | null {
|
|
921
|
+
const match = WORKSPACE_PATH_PATTERN.exec(pathname);
|
|
922
|
+
return match ? `workspace:${match[1]!.toLowerCase()}` : null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Resolves the trust/keying subject keys for a request. Limited to
|
|
927
|
+
* session/cidr/ip (keys an attacker would have to actually possess or share a
|
|
928
|
+
* network with) plus a workspace key parsed from the path — so trust uplift and
|
|
929
|
+
* per-session keying can never be granted by a forged credential.
|
|
930
|
+
*/
|
|
931
|
+
async function resolveProxyIdentity(
|
|
932
|
+
req: NextRequest,
|
|
933
|
+
ip: string
|
|
934
|
+
): Promise<ProxyIdentity> {
|
|
935
|
+
const normalizedIp = ip && ip !== 'unknown' ? ip : null;
|
|
936
|
+
const sessionCookie = getSessionAuthCookie(req);
|
|
937
|
+
const sessionKey = sessionCookie
|
|
938
|
+
? `session:${await hashStableSubjectEdge(`${sessionCookie.name}:${sessionCookie.value}`)}`
|
|
939
|
+
: null;
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
cidrKey: getCidrSubjectKeyEdge(normalizedIp),
|
|
943
|
+
ipKey: normalizedIp ? `ip:${normalizedIp}` : null,
|
|
944
|
+
sessionKey,
|
|
945
|
+
workspaceKey: getWorkspaceSubjectKey(req.nextUrl.pathname),
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Scales the read (`get`) limits of a profile by a trust multiplier. Mutation
|
|
951
|
+
* limits are left flat — those are already trust-scaled at the DB and in the
|
|
952
|
+
* downstream session/API wrappers, so scaling them here would double-apply.
|
|
953
|
+
*/
|
|
954
|
+
function scaleReadLimits(
|
|
955
|
+
profile: RateLimitProfile,
|
|
956
|
+
multiplier: number
|
|
957
|
+
): RateLimitProfile {
|
|
958
|
+
if (multiplier <= 1) {
|
|
959
|
+
return profile;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
...profile,
|
|
964
|
+
get: profile.get.map((config) => ({
|
|
965
|
+
...config,
|
|
966
|
+
limit: Math.max(config.limit, Math.floor(config.limit * multiplier)),
|
|
967
|
+
})),
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function entryReadFloor(abs: NonNullable<CachedTrustEntry['abs']>): number {
|
|
972
|
+
return abs.minute ?? abs.hour ?? abs.day ?? 0;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Combines all applicable cached entries (session + location/workspace) into a
|
|
977
|
+
* single effective read decision. Most-permissive wins: unlimited beats
|
|
978
|
+
* absolute beats the highest multiplier.
|
|
979
|
+
*/
|
|
980
|
+
function combineTrustEntries(
|
|
981
|
+
entries: Array<CachedTrustEntry | null | undefined>
|
|
982
|
+
): CachedTrustEntry | null {
|
|
983
|
+
const active = entries.filter((entry): entry is CachedTrustEntry => !!entry);
|
|
984
|
+
if (active.length === 0) {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (active.some((entry) => entry.mode === 'unlimited')) {
|
|
989
|
+
return { m: 1, mode: 'unlimited' };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const absolutes = active.filter(
|
|
993
|
+
(
|
|
994
|
+
entry
|
|
995
|
+
): entry is CachedTrustEntry & {
|
|
996
|
+
abs: NonNullable<CachedTrustEntry['abs']>;
|
|
997
|
+
} => entry.mode === 'absolute' && !!entry.abs
|
|
998
|
+
);
|
|
999
|
+
if (absolutes.length > 0) {
|
|
1000
|
+
const best = absolutes.reduce((winner, candidate) =>
|
|
1001
|
+
entryReadFloor(candidate.abs) > entryReadFloor(winner.abs)
|
|
1002
|
+
? candidate
|
|
1003
|
+
: winner
|
|
1004
|
+
);
|
|
1005
|
+
return { abs: best.abs, m: best.m, mode: 'absolute' };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return { m: Math.max(...active.map((entry) => entry.m)) };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Applies a combined trust entry to a route profile's READ limits and returns
|
|
1013
|
+
* the scaled profile plus a bucket-prefix suffix that uniquely encodes the
|
|
1014
|
+
* effective limit (so distinct limits never share a Redis sliding window).
|
|
1015
|
+
*/
|
|
1016
|
+
function applyTrustEntryToReads(
|
|
1017
|
+
profile: RateLimitProfile,
|
|
1018
|
+
entry: CachedTrustEntry | null
|
|
1019
|
+
): { profile: RateLimitProfile; suffix: string } {
|
|
1020
|
+
if (!entry) {
|
|
1021
|
+
return { profile, suffix: '' };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (entry.mode === 'unlimited') {
|
|
1025
|
+
// Drop the read cap entirely (no read limiter is constructed for [] get).
|
|
1026
|
+
return { profile: { ...profile, get: [] }, suffix: ':unl' };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (entry.mode === 'absolute' && entry.abs) {
|
|
1030
|
+
const get = profile.get.map((config) => {
|
|
1031
|
+
const absolute = entry.abs?.[config.window];
|
|
1032
|
+
return absolute != null ? { ...config, limit: absolute } : config;
|
|
1033
|
+
});
|
|
1034
|
+
const { minute, hour, day } = entry.abs;
|
|
1035
|
+
return {
|
|
1036
|
+
profile: { ...profile, get },
|
|
1037
|
+
suffix: `:abs-${minute ?? 'x'}-${hour ?? 'x'}-${day ?? 'x'}`,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const tier = quantizeTrustMultiplier(entry.m);
|
|
1042
|
+
if (tier <= 1) {
|
|
1043
|
+
return { profile, suffix: '' };
|
|
1044
|
+
}
|
|
1045
|
+
return { profile: scaleReadLimits(profile, tier), suffix: `:t${tier}` };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
749
1048
|
function getEffectiveRateLimits(
|
|
750
1049
|
routePolicy: ProxyRoutePolicy,
|
|
751
1050
|
callerClass: CallerClass
|
|
@@ -764,21 +1063,17 @@ function getEffectiveRateLimits(
|
|
|
764
1063
|
return routePolicy.rateLimits;
|
|
765
1064
|
}
|
|
766
1065
|
|
|
767
|
-
function
|
|
768
|
-
return routePolicy.key === 'default' || routePolicy.key === 'users-me';
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
function getPathScopedRateLimitPrefix(
|
|
1066
|
+
function getRateLimitPrefix(
|
|
772
1067
|
prefixBase: string,
|
|
773
1068
|
routePolicy: ProxyRoutePolicy,
|
|
774
1069
|
callerClass: CallerClass,
|
|
775
|
-
|
|
1070
|
+
trustSuffix = ''
|
|
776
1071
|
): string {
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
return `${prefixBase}:${routePolicy.key}:${callerClass}${
|
|
1072
|
+
// Only trusted/uplifted traffic gets a suffixed bucket; untrusted traffic
|
|
1073
|
+
// keeps the original prefix so its sliding windows are unaffected. The suffix
|
|
1074
|
+
// must uniquely encode the effective limit (tier or absolute values) so two
|
|
1075
|
+
// different effective limits never share a Redis sliding-window prefix.
|
|
1076
|
+
return `${prefixBase}:${routePolicy.key}:${callerClass}${trustSuffix}`;
|
|
782
1077
|
}
|
|
783
1078
|
|
|
784
1079
|
export function isTrustedProxyBypassRequest(
|
|
@@ -846,7 +1141,10 @@ export async function guardApiProxyRequest(
|
|
|
846
1141
|
);
|
|
847
1142
|
}
|
|
848
1143
|
|
|
849
|
-
const routePolicies =
|
|
1144
|
+
const routePolicies = [
|
|
1145
|
+
...(options.additionalRoutePolicies ?? []),
|
|
1146
|
+
...(options.routePolicies ?? DEFAULT_ROUTE_POLICIES),
|
|
1147
|
+
];
|
|
850
1148
|
const trustedBypassRules = [
|
|
851
1149
|
...DEFAULT_TRUSTED_BYPASS_RULES,
|
|
852
1150
|
...(options.trustedBypassRules ?? []),
|
|
@@ -862,8 +1160,9 @@ export async function guardApiProxyRequest(
|
|
|
862
1160
|
) {
|
|
863
1161
|
const callerClass = getCallerClass(req);
|
|
864
1162
|
const ip = extractIPFromRequest(req.headers);
|
|
1163
|
+
const identity = await resolveProxyIdentity(req, ip);
|
|
865
1164
|
|
|
866
|
-
if (ip !== 'unknown'
|
|
1165
|
+
if (ip !== 'unknown') {
|
|
867
1166
|
const blockInfo = await isIPBlockedEdge(ip);
|
|
868
1167
|
if (blockInfo) {
|
|
869
1168
|
const retryAfter = Math.max(
|
|
@@ -875,15 +1174,63 @@ export async function guardApiProxyRequest(
|
|
|
875
1174
|
'X-Proxy-Block-Reason': 'ip-already-blocked',
|
|
876
1175
|
});
|
|
877
1176
|
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
1180
|
+
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
1181
|
+
|
|
1182
|
+
// Resolve trust for reads only. Account trust (session key) and location
|
|
1183
|
+
// trust (cidr/ip keys) are distinguished so that:
|
|
1184
|
+
// * a genuinely trusted SESSION (cache entry keyed on the real cookie's
|
|
1185
|
+
// hash — unforgeable) gets its OWN per-session bucket, so trusted
|
|
1186
|
+
// teammates behind one NAT no longer collide; and
|
|
1187
|
+
// * a trusted LOCATION (office cidr/ip) uplifts the shared per-IP bucket
|
|
1188
|
+
// for the whole team without any per-request credential.
|
|
1189
|
+
// Untrusted traffic keeps legacy per-IP keying, preserving single-IP abuse
|
|
1190
|
+
// caps. A forged cookie hashes to a key with no cache entry → no uplift,
|
|
1191
|
+
// no per-session escape.
|
|
1192
|
+
let trustEntry: CachedTrustEntry | null = null;
|
|
1193
|
+
let sessionTrusted = false;
|
|
1194
|
+
if (isRead && edgeTrustEnabled()) {
|
|
1195
|
+
const lookupKeys = [
|
|
1196
|
+
identity.sessionKey,
|
|
1197
|
+
identity.cidrKey,
|
|
1198
|
+
identity.ipKey,
|
|
1199
|
+
identity.workspaceKey,
|
|
1200
|
+
].filter((key): key is string => key != null);
|
|
1201
|
+
const entries = await getCachedTrustEntries(lookupKeys);
|
|
1202
|
+
|
|
1203
|
+
const sessionEntry = identity.sessionKey
|
|
1204
|
+
? entries.get(identity.sessionKey)
|
|
1205
|
+
: undefined;
|
|
1206
|
+
// Location/workspace trust applies to the shared per-IP bucket for the
|
|
1207
|
+
// whole team; session trust applies to the per-session bucket.
|
|
1208
|
+
const locationEntry = combineTrustEntries([
|
|
1209
|
+
identity.cidrKey ? entries.get(identity.cidrKey) : undefined,
|
|
1210
|
+
identity.ipKey ? entries.get(identity.ipKey) : undefined,
|
|
1211
|
+
identity.workspaceKey ? entries.get(identity.workspaceKey) : undefined,
|
|
1212
|
+
]);
|
|
1213
|
+
|
|
1214
|
+
trustEntry = combineTrustEntries([sessionEntry, locationEntry]);
|
|
1215
|
+
sessionTrusted = !!identity.sessionKey && !!sessionEntry;
|
|
1216
|
+
}
|
|
878
1217
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1218
|
+
// Trusted sessions key per-session; everyone else keys per-IP.
|
|
1219
|
+
const limiterId =
|
|
1220
|
+
sessionTrusted && identity.sessionKey
|
|
1221
|
+
? identity.sessionKey
|
|
1222
|
+
: identity.ipKey;
|
|
1223
|
+
|
|
1224
|
+
if (limiterId) {
|
|
1225
|
+
const baseRateLimits = getEffectiveRateLimits(routePolicy, callerClass);
|
|
1226
|
+
const { profile: rateLimits, suffix: trustSuffix } =
|
|
1227
|
+
applyTrustEntryToReads(baseRateLimits, trustEntry);
|
|
1228
|
+
|
|
1229
|
+
const limiterPrefix = getRateLimitPrefix(
|
|
883
1230
|
options.prefixBase,
|
|
884
1231
|
routePolicy,
|
|
885
1232
|
callerClass,
|
|
886
|
-
|
|
1233
|
+
trustSuffix
|
|
887
1234
|
);
|
|
888
1235
|
const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
|
|
889
1236
|
const limiters = await getRateLimiters(limiterPrefix, rateLimits);
|
|
@@ -894,7 +1241,7 @@ export async function guardApiProxyRequest(
|
|
|
894
1241
|
let result: RateLimitResult;
|
|
895
1242
|
|
|
896
1243
|
try {
|
|
897
|
-
result = await limiter.limit(
|
|
1244
|
+
result = await limiter.limit(limiterId);
|
|
898
1245
|
} catch {
|
|
899
1246
|
const fallbackLimiters = replaceWithLocalRateLimiters(
|
|
900
1247
|
limiterCacheKey,
|
|
@@ -912,7 +1259,7 @@ export async function guardApiProxyRequest(
|
|
|
912
1259
|
continue;
|
|
913
1260
|
}
|
|
914
1261
|
|
|
915
|
-
result = await fallbackBucket.limiter.limit(
|
|
1262
|
+
result = await fallbackBucket.limiter.limit(limiterId);
|
|
916
1263
|
}
|
|
917
1264
|
|
|
918
1265
|
const { success, limit, remaining, reset } = result;
|
|
@@ -921,7 +1268,7 @@ export async function guardApiProxyRequest(
|
|
|
921
1268
|
const consumed = limit - remaining;
|
|
922
1269
|
const kind = isRead ? 'read' : 'mutate';
|
|
923
1270
|
console.log(
|
|
924
|
-
`[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} |
|
|
1271
|
+
`[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | id: ${limiterId} | path: ${req.nextUrl.pathname}`
|
|
925
1272
|
);
|
|
926
1273
|
}
|
|
927
1274
|
|
package/src/internal-domains.ts
CHANGED
|
@@ -370,12 +370,53 @@ function canUpgradeToRegisteredHttpsOrigin(value: URL, registeredUrl: URL) {
|
|
|
370
370
|
}
|
|
371
371
|
|
|
372
372
|
const PORTLESS_APP_HOSTS = new Set(Object.values(TUTURUUU_PORTLESS_APP_HOSTS));
|
|
373
|
+
const DEFAULT_PORTLESS_PROXY_PORT = '1355';
|
|
374
|
+
|
|
375
|
+
function normalizePortlessProxyPort(value: string | undefined) {
|
|
376
|
+
const trimmed = value?.trim();
|
|
377
|
+
|
|
378
|
+
if (!trimmed || !/^\d+$/u.test(trimmed)) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const port = Number(trimmed);
|
|
383
|
+
return Number.isInteger(port) && port > 0 && port <= 65535
|
|
384
|
+
? String(port)
|
|
385
|
+
: null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function getPortFromUrl(value: string | undefined) {
|
|
389
|
+
if (!value?.trim()) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
return normalizePortlessProxyPort(new URL(value).port);
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function getAllowedPortlessProxyPorts() {
|
|
401
|
+
return new Set(
|
|
402
|
+
[
|
|
403
|
+
DEFAULT_PORTLESS_PROXY_PORT,
|
|
404
|
+
normalizePortlessProxyPort(process.env.PORTLESS_PORT),
|
|
405
|
+
getPortFromUrl(process.env.PORTLESS_URL),
|
|
406
|
+
].filter((port): port is string => Boolean(port))
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isAllowedPortlessProxyPort(port: string) {
|
|
411
|
+
return getAllowedPortlessProxyPorts().has(port);
|
|
412
|
+
}
|
|
373
413
|
|
|
374
414
|
function canUseLocalPortlessProxyPort(value: URL, registeredUrl: URL) {
|
|
375
415
|
return (
|
|
376
416
|
value.protocol === registeredUrl.protocol &&
|
|
377
417
|
!registeredUrl.port &&
|
|
378
418
|
Boolean(value.port) &&
|
|
419
|
+
isAllowedPortlessProxyPort(value.port) &&
|
|
379
420
|
PORTLESS_APP_HOSTS.has(registeredUrl.hostname)
|
|
380
421
|
);
|
|
381
422
|
}
|
|
@@ -447,16 +488,27 @@ function matchesAppDomainUrl(value: URL, domain: AppDomain) {
|
|
|
447
488
|
|
|
448
489
|
function canonicalizeAppDomainUrl(value: URL, domain: AppDomain) {
|
|
449
490
|
const registeredUrl = parseHttpUrl(domain.url);
|
|
491
|
+
const matchesDirectPortlessProxy =
|
|
492
|
+
registeredUrl &&
|
|
493
|
+
domain.kind === 'internal' &&
|
|
494
|
+
matchesPortlessProxyOrigin(value, registeredUrl);
|
|
495
|
+
const matchesPrefixedPortlessProxy =
|
|
496
|
+
registeredUrl &&
|
|
497
|
+
domain.kind === 'internal' &&
|
|
498
|
+
matchesPrefixedPortlessOrigin(value, registeredUrl);
|
|
450
499
|
|
|
451
500
|
if (
|
|
452
501
|
registeredUrl &&
|
|
453
502
|
domain.kind === 'internal' &&
|
|
454
503
|
(canUpgradeToRegisteredHttpsOrigin(value, registeredUrl) ||
|
|
455
|
-
|
|
504
|
+
matchesDirectPortlessProxy ||
|
|
505
|
+
matchesPrefixedPortlessProxy)
|
|
456
506
|
) {
|
|
457
507
|
const canonicalUrl = new URL(value.toString());
|
|
458
508
|
canonicalUrl.protocol = registeredUrl.protocol;
|
|
459
|
-
canonicalUrl.hostname =
|
|
509
|
+
canonicalUrl.hostname = matchesPrefixedPortlessProxy
|
|
510
|
+
? value.hostname
|
|
511
|
+
: registeredUrl.hostname;
|
|
460
512
|
canonicalUrl.port = registeredUrl.port;
|
|
461
513
|
return serializeUrl(canonicalUrl);
|
|
462
514
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
formatMoneyFromMinor,
|
|
4
|
+
getAmountStep,
|
|
5
|
+
getCurrencyFractionDigits,
|
|
6
|
+
getMinorUnitFactor,
|
|
7
|
+
majorToMinor,
|
|
8
|
+
minorToMajor,
|
|
9
|
+
normalizeAmount,
|
|
10
|
+
} from './money';
|
|
11
|
+
|
|
12
|
+
describe('currency precision', () => {
|
|
13
|
+
it('knows fraction digits per currency', () => {
|
|
14
|
+
expect(getCurrencyFractionDigits('USD')).toBe(2);
|
|
15
|
+
expect(getCurrencyFractionDigits('EUR')).toBe(2);
|
|
16
|
+
expect(getCurrencyFractionDigits('JPY')).toBe(0);
|
|
17
|
+
expect(getCurrencyFractionDigits('VND')).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('falls back to 2 digits for unknown currencies', () => {
|
|
21
|
+
expect(getCurrencyFractionDigits('XYZ')).toBe(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('derives the minor-unit factor', () => {
|
|
25
|
+
expect(getMinorUnitFactor('USD')).toBe(100);
|
|
26
|
+
expect(getMinorUnitFactor('JPY')).toBe(1);
|
|
27
|
+
expect(getMinorUnitFactor('VND')).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('major <-> minor conversion', () => {
|
|
32
|
+
it('converts dollars to cents', () => {
|
|
33
|
+
expect(majorToMinor(100, 'USD')).toBe(10000);
|
|
34
|
+
expect(majorToMinor(9.99, 'USD')).toBe(999);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('treats zero-decimal currencies 1:1', () => {
|
|
38
|
+
expect(majorToMinor(500, 'JPY')).toBe(500);
|
|
39
|
+
expect(majorToMinor(25000, 'VND')).toBe(25000);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('round-trips cleanly', () => {
|
|
43
|
+
expect(minorToMajor(10000, 'USD')).toBe(100);
|
|
44
|
+
expect(minorToMajor(999, 'USD')).toBe(9.99);
|
|
45
|
+
expect(minorToMajor(500, 'JPY')).toBe(500);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rounds rather than truncating fractional minor units', () => {
|
|
49
|
+
expect(majorToMinor(1.005, 'USD')).toBe(101);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('handles non-finite input defensively', () => {
|
|
53
|
+
expect(majorToMinor(Number.NaN, 'USD')).toBe(0);
|
|
54
|
+
expect(minorToMajor(Number.POSITIVE_INFINITY, 'USD')).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('normalizeAmount & getAmountStep', () => {
|
|
59
|
+
it('normalizes to currency precision', () => {
|
|
60
|
+
expect(normalizeAmount(9.999, 'USD')).toBe(10);
|
|
61
|
+
expect(normalizeAmount(9.994, 'USD')).toBe(9.99);
|
|
62
|
+
expect(normalizeAmount(500.7, 'JPY')).toBe(501);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns the right input step', () => {
|
|
66
|
+
expect(getAmountStep('USD')).toBe('0.01');
|
|
67
|
+
expect(getAmountStep('JPY')).toBe('1');
|
|
68
|
+
expect(getAmountStep('VND')).toBe('1');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('formatMoneyFromMinor', () => {
|
|
73
|
+
it('formats USD cents as dollars', () => {
|
|
74
|
+
// Non-breaking space / symbol placement varies by ICU; assert the digits.
|
|
75
|
+
expect(formatMoneyFromMinor(10000, 'USD', 'en-US')).toContain('100.00');
|
|
76
|
+
expect(formatMoneyFromMinor(999, 'USD', 'en-US')).toContain('9.99');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('formats zero-decimal currencies without scaling', () => {
|
|
80
|
+
expect(formatMoneyFromMinor(500, 'JPY', 'ja-JP')).toContain('500');
|
|
81
|
+
});
|
|
82
|
+
});
|