@tuturuuu/utils 0.7.0 → 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 +28 -0
- package/package.json +5 -4
- package/src/__tests__/api-proxy-guard.test.ts +419 -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 +347 -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/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,11 +294,11 @@ 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
|
};
|
|
@@ -766,10 +810,241 @@ export function hasAuthenticatedApiSession(req: NextRequest): boolean {
|
|
|
766
810
|
}
|
|
767
811
|
|
|
768
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.
|
|
769
819
|
void req;
|
|
770
820
|
return 'anonymous';
|
|
771
821
|
}
|
|
772
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
|
+
|
|
773
1048
|
function getEffectiveRateLimits(
|
|
774
1049
|
routePolicy: ProxyRoutePolicy,
|
|
775
1050
|
callerClass: CallerClass
|
|
@@ -788,21 +1063,17 @@ function getEffectiveRateLimits(
|
|
|
788
1063
|
return routePolicy.rateLimits;
|
|
789
1064
|
}
|
|
790
1065
|
|
|
791
|
-
function
|
|
792
|
-
return routePolicy.key === 'default' || routePolicy.key === 'users-me';
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function getPathScopedRateLimitPrefix(
|
|
1066
|
+
function getRateLimitPrefix(
|
|
796
1067
|
prefixBase: string,
|
|
797
1068
|
routePolicy: ProxyRoutePolicy,
|
|
798
1069
|
callerClass: CallerClass,
|
|
799
|
-
|
|
1070
|
+
trustSuffix = ''
|
|
800
1071
|
): string {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
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}`;
|
|
806
1077
|
}
|
|
807
1078
|
|
|
808
1079
|
export function isTrustedProxyBypassRequest(
|
|
@@ -870,7 +1141,10 @@ export async function guardApiProxyRequest(
|
|
|
870
1141
|
);
|
|
871
1142
|
}
|
|
872
1143
|
|
|
873
|
-
const routePolicies =
|
|
1144
|
+
const routePolicies = [
|
|
1145
|
+
...(options.additionalRoutePolicies ?? []),
|
|
1146
|
+
...(options.routePolicies ?? DEFAULT_ROUTE_POLICIES),
|
|
1147
|
+
];
|
|
874
1148
|
const trustedBypassRules = [
|
|
875
1149
|
...DEFAULT_TRUSTED_BYPASS_RULES,
|
|
876
1150
|
...(options.trustedBypassRules ?? []),
|
|
@@ -886,8 +1160,9 @@ export async function guardApiProxyRequest(
|
|
|
886
1160
|
) {
|
|
887
1161
|
const callerClass = getCallerClass(req);
|
|
888
1162
|
const ip = extractIPFromRequest(req.headers);
|
|
1163
|
+
const identity = await resolveProxyIdentity(req, ip);
|
|
889
1164
|
|
|
890
|
-
if (ip !== 'unknown'
|
|
1165
|
+
if (ip !== 'unknown') {
|
|
891
1166
|
const blockInfo = await isIPBlockedEdge(ip);
|
|
892
1167
|
if (blockInfo) {
|
|
893
1168
|
const retryAfter = Math.max(
|
|
@@ -899,15 +1174,63 @@ export async function guardApiProxyRequest(
|
|
|
899
1174
|
'X-Proxy-Block-Reason': 'ip-already-blocked',
|
|
900
1175
|
});
|
|
901
1176
|
}
|
|
1177
|
+
}
|
|
902
1178
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
+
}
|
|
1217
|
+
|
|
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(
|
|
907
1230
|
options.prefixBase,
|
|
908
1231
|
routePolicy,
|
|
909
1232
|
callerClass,
|
|
910
|
-
|
|
1233
|
+
trustSuffix
|
|
911
1234
|
);
|
|
912
1235
|
const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
|
|
913
1236
|
const limiters = await getRateLimiters(limiterPrefix, rateLimits);
|
|
@@ -918,7 +1241,7 @@ export async function guardApiProxyRequest(
|
|
|
918
1241
|
let result: RateLimitResult;
|
|
919
1242
|
|
|
920
1243
|
try {
|
|
921
|
-
result = await limiter.limit(
|
|
1244
|
+
result = await limiter.limit(limiterId);
|
|
922
1245
|
} catch {
|
|
923
1246
|
const fallbackLimiters = replaceWithLocalRateLimiters(
|
|
924
1247
|
limiterCacheKey,
|
|
@@ -936,7 +1259,7 @@ export async function guardApiProxyRequest(
|
|
|
936
1259
|
continue;
|
|
937
1260
|
}
|
|
938
1261
|
|
|
939
|
-
result = await fallbackBucket.limiter.limit(
|
|
1262
|
+
result = await fallbackBucket.limiter.limit(limiterId);
|
|
940
1263
|
}
|
|
941
1264
|
|
|
942
1265
|
const { success, limit, remaining, reset } = result;
|
|
@@ -945,7 +1268,7 @@ export async function guardApiProxyRequest(
|
|
|
945
1268
|
const consumed = limit - remaining;
|
|
946
1269
|
const kind = isRead ? 'read' : 'mutate';
|
|
947
1270
|
console.log(
|
|
948
|
-
`[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} |
|
|
1271
|
+
`[ProxyGuard] ${routePolicy.key}:${kind}:${window} ${consumed}/${limit} | id: ${limiterId} | path: ${req.nextUrl.pathname}`
|
|
949
1272
|
);
|
|
950
1273
|
}
|
|
951
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
|
+
});
|