@holo-js/security 0.1.3
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/dist/chunk-3J5QRTPZ.mjs +156 -0
- package/dist/client.d.ts +19 -0
- package/dist/client.mjs +47 -0
- package/dist/contracts.d.ts +137 -0
- package/dist/contracts.mjs +24 -0
- package/dist/drivers/redis-adapter.d.ts +71 -0
- package/dist/drivers/redis-adapter.mjs +212 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.mjs +916 -0
- package/package.json +58 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/contracts.ts
|
|
2
|
+
import { normalizeSecurityConfig } from "@holo-js/config";
|
|
3
|
+
var SecurityCsrfError = class extends Error {
|
|
4
|
+
status = 419;
|
|
5
|
+
constructor(message = "CSRF token mismatch.") {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "SecurityCsrfError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var SecurityRateLimitError = class extends Error {
|
|
11
|
+
status = 429;
|
|
12
|
+
retryAfterSeconds;
|
|
13
|
+
snapshot;
|
|
14
|
+
constructor(message = "Too many attempts. Please try again later.", options = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "SecurityRateLimitError";
|
|
17
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
18
|
+
this.snapshot = options.snapshot;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var PendingSecurityLimiterDefinition = class {
|
|
22
|
+
constructor(maxAttempts, decaySeconds) {
|
|
23
|
+
this.maxAttempts = maxAttempts;
|
|
24
|
+
this.decaySeconds = decaySeconds;
|
|
25
|
+
}
|
|
26
|
+
by(key) {
|
|
27
|
+
if (typeof key !== "function") {
|
|
28
|
+
throw new TypeError("[@holo-js/security] Rate limiter key resolvers must be functions.");
|
|
29
|
+
}
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
maxAttempts: this.maxAttempts,
|
|
32
|
+
decaySeconds: this.decaySeconds,
|
|
33
|
+
key
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
define() {
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
maxAttempts: this.maxAttempts,
|
|
39
|
+
decaySeconds: this.decaySeconds
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function normalizeLimiterAttempts(value, label) {
|
|
44
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
45
|
+
throw new TypeError(`[@holo-js/security] ${label} must be an integer greater than or equal to 1.`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
function normalizeLimiterWindowSeconds(value, label) {
|
|
50
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
51
|
+
throw new TypeError(`[@holo-js/security] ${label} must be an integer greater than or equal to 1.`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
var limit = Object.freeze({
|
|
56
|
+
perMinute(maxAttempts) {
|
|
57
|
+
return new PendingSecurityLimiterDefinition(
|
|
58
|
+
normalizeLimiterAttempts(maxAttempts, "Rate limiter maxAttempts"),
|
|
59
|
+
normalizeLimiterWindowSeconds(60, "Rate limiter decaySeconds")
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
perHour(maxAttempts) {
|
|
63
|
+
return new PendingSecurityLimiterDefinition(
|
|
64
|
+
normalizeLimiterAttempts(maxAttempts, "Rate limiter maxAttempts"),
|
|
65
|
+
normalizeLimiterWindowSeconds(3600, "Rate limiter decaySeconds")
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
function ip(request, trustedProxy = false) {
|
|
70
|
+
if (!trustedProxy) {
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
const forwarded = request.headers.get("x-forwarded-for")?.split(",", 1)[0]?.trim();
|
|
74
|
+
if (forwarded) {
|
|
75
|
+
return forwarded;
|
|
76
|
+
}
|
|
77
|
+
const realIp = request.headers.get("x-real-ip")?.trim();
|
|
78
|
+
if (realIp) {
|
|
79
|
+
return realIp;
|
|
80
|
+
}
|
|
81
|
+
return "unknown";
|
|
82
|
+
}
|
|
83
|
+
function normalizeLimiterIntegerInput(value, label) {
|
|
84
|
+
if (typeof value === "undefined") {
|
|
85
|
+
throw new TypeError(`[@holo-js/security] ${label} is required.`);
|
|
86
|
+
}
|
|
87
|
+
const normalized = typeof value === "number" ? value : (() => {
|
|
88
|
+
const trimmed = value.trim();
|
|
89
|
+
if (!trimmed) {
|
|
90
|
+
return Number.NaN;
|
|
91
|
+
}
|
|
92
|
+
return Number(trimmed);
|
|
93
|
+
})();
|
|
94
|
+
if (!Number.isFinite(normalized) || !Number.isInteger(normalized)) {
|
|
95
|
+
throw new TypeError(`[@holo-js/security] ${label} must be an integer greater than or equal to 1.`);
|
|
96
|
+
}
|
|
97
|
+
if (normalized < 1) {
|
|
98
|
+
throw new TypeError(`[@holo-js/security] ${label} must be an integer greater than or equal to 1.`);
|
|
99
|
+
}
|
|
100
|
+
return normalized;
|
|
101
|
+
}
|
|
102
|
+
function defineRateLimiter(definition) {
|
|
103
|
+
if (!definition || typeof definition !== "object") {
|
|
104
|
+
throw new TypeError("[@holo-js/security] Rate limiter definitions must be objects.");
|
|
105
|
+
}
|
|
106
|
+
const normalizedDefinition = {
|
|
107
|
+
...definition,
|
|
108
|
+
maxAttempts: normalizeLimiterIntegerInput(definition.maxAttempts, "Rate limiter maxAttempts"),
|
|
109
|
+
decaySeconds: normalizeLimiterIntegerInput(definition.decaySeconds, "Rate limiter decaySeconds")
|
|
110
|
+
};
|
|
111
|
+
if (normalizedDefinition.key !== void 0 && typeof normalizedDefinition.key !== "function") {
|
|
112
|
+
throw new TypeError("[@holo-js/security] Rate limiter key resolvers must be functions.");
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze(normalizedDefinition);
|
|
115
|
+
}
|
|
116
|
+
function defineSecurityRuntimeBindings(bindings) {
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
config: normalizeSecurityConfig(bindings.config),
|
|
119
|
+
rateLimitStore: bindings.rateLimitStore,
|
|
120
|
+
csrfSigningKey: bindings.csrfSigningKey,
|
|
121
|
+
defaultKeyResolver: bindings.defaultKeyResolver
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function createMemoryRateLimitStoreConfig(config = {}) {
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
...config
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function createFileRateLimitStoreConfig(config = {}) {
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
...config
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function createRedisRateLimitStoreConfig(config = {}) {
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
...config
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
var securityInternals = {
|
|
140
|
+
PendingSecurityLimiterDefinition,
|
|
141
|
+
normalizeLimiterAttempts,
|
|
142
|
+
normalizeLimiterWindowSeconds
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export {
|
|
146
|
+
SecurityCsrfError,
|
|
147
|
+
SecurityRateLimitError,
|
|
148
|
+
limit,
|
|
149
|
+
ip,
|
|
150
|
+
defineRateLimiter,
|
|
151
|
+
defineSecurityRuntimeBindings,
|
|
152
|
+
createMemoryRateLimitStoreConfig,
|
|
153
|
+
createFileRateLimitStoreConfig,
|
|
154
|
+
createRedisRateLimitStoreConfig,
|
|
155
|
+
securityInternals
|
|
156
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SecurityClientBindings, SecurityClientConfig } from './contracts.js';
|
|
2
|
+
import '@holo-js/config';
|
|
3
|
+
|
|
4
|
+
type RuntimeSecurityClientState = {
|
|
5
|
+
bindings?: SecurityClientConfig;
|
|
6
|
+
};
|
|
7
|
+
declare function getDefaultSecurityClientConfig(): SecurityClientConfig;
|
|
8
|
+
declare function getSecurityClientState(): RuntimeSecurityClientState;
|
|
9
|
+
declare function normalizeSecurityClientConfig(bindings?: SecurityClientBindings): SecurityClientConfig;
|
|
10
|
+
declare function configureSecurityClient(bindings?: SecurityClientBindings): void;
|
|
11
|
+
declare function getSecurityClientConfig(): SecurityClientConfig;
|
|
12
|
+
declare function resetSecurityClient(): void;
|
|
13
|
+
declare const securityClientInternals: {
|
|
14
|
+
getDefaultSecurityClientConfig: typeof getDefaultSecurityClientConfig;
|
|
15
|
+
getSecurityClientState: typeof getSecurityClientState;
|
|
16
|
+
normalizeSecurityClientConfig: typeof normalizeSecurityClientConfig;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export { SecurityClientBindings, SecurityClientConfig, configureSecurityClient, getSecurityClientConfig, resetSecurityClient, securityClientInternals };
|
package/dist/client.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { normalizeSecurityConfig } from "@holo-js/config";
|
|
3
|
+
var DEFAULT_SECURITY_CONFIG = normalizeSecurityConfig({});
|
|
4
|
+
var DEFAULT_SECURITY_CLIENT_CONFIG = Object.freeze({
|
|
5
|
+
csrf: Object.freeze({
|
|
6
|
+
field: DEFAULT_SECURITY_CONFIG.csrf.field,
|
|
7
|
+
cookie: DEFAULT_SECURITY_CONFIG.csrf.cookie
|
|
8
|
+
})
|
|
9
|
+
});
|
|
10
|
+
function getDefaultSecurityClientConfig() {
|
|
11
|
+
return DEFAULT_SECURITY_CLIENT_CONFIG;
|
|
12
|
+
}
|
|
13
|
+
function getSecurityClientState() {
|
|
14
|
+
const runtime = globalThis;
|
|
15
|
+
runtime.__holoSecurityClient__ ??= {};
|
|
16
|
+
return runtime.__holoSecurityClient__;
|
|
17
|
+
}
|
|
18
|
+
function normalizeSecurityClientConfig(bindings) {
|
|
19
|
+
const defaults = getDefaultSecurityClientConfig();
|
|
20
|
+
const csrf = Object.freeze({
|
|
21
|
+
field: bindings?.config?.csrf?.field ?? defaults.csrf.field,
|
|
22
|
+
cookie: bindings?.config?.csrf?.cookie ?? defaults.csrf.cookie
|
|
23
|
+
});
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
csrf
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function configureSecurityClient(bindings) {
|
|
29
|
+
getSecurityClientState().bindings = bindings ? normalizeSecurityClientConfig(bindings) : void 0;
|
|
30
|
+
}
|
|
31
|
+
function getSecurityClientConfig() {
|
|
32
|
+
return getSecurityClientState().bindings ?? DEFAULT_SECURITY_CLIENT_CONFIG;
|
|
33
|
+
}
|
|
34
|
+
function resetSecurityClient() {
|
|
35
|
+
getSecurityClientState().bindings = void 0;
|
|
36
|
+
}
|
|
37
|
+
var securityClientInternals = {
|
|
38
|
+
getDefaultSecurityClientConfig,
|
|
39
|
+
getSecurityClientState,
|
|
40
|
+
normalizeSecurityClientConfig
|
|
41
|
+
};
|
|
42
|
+
export {
|
|
43
|
+
configureSecurityClient,
|
|
44
|
+
getSecurityClientConfig,
|
|
45
|
+
resetSecurityClient,
|
|
46
|
+
securityClientInternals
|
|
47
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { HoloSecurityConfig, NormalizedHoloSecurityConfig, SecurityRateLimitFileConfig, SecurityRateLimitMemoryConfig, SecurityRateLimitRedisConfig, SecurityLimiterConfig, SecurityRateLimitKeyResolver } from '@holo-js/config';
|
|
2
|
+
export { HoloSecurityConfig, HoloSecurityCsrfConfig, HoloSecurityRateLimitConfig, NormalizedHoloSecurityConfig, NormalizedHoloSecurityCsrfConfig, NormalizedHoloSecurityRateLimitConfig, NormalizedSecurityLimiterConfig, SecurityLimiterConfig, SecurityRateLimitContext, SecurityRateLimitDriver, SecurityRateLimitFileConfig, SecurityRateLimitKeyResolver, SecurityRateLimitMemoryConfig, SecurityRateLimitRedisConfig } from '@holo-js/config';
|
|
3
|
+
|
|
4
|
+
interface SecurityRuntimeBindings {
|
|
5
|
+
readonly config: HoloSecurityConfig | NormalizedHoloSecurityConfig;
|
|
6
|
+
readonly rateLimitStore?: SecurityRateLimitStore;
|
|
7
|
+
readonly csrfSigningKey?: string;
|
|
8
|
+
readonly defaultKeyResolver?: SecurityDefaultRateLimitKeyResolver;
|
|
9
|
+
}
|
|
10
|
+
interface SecurityRuntimeFacade {
|
|
11
|
+
readonly config: NormalizedHoloSecurityConfig;
|
|
12
|
+
readonly rateLimitStore?: SecurityRateLimitStore;
|
|
13
|
+
readonly csrfSigningKey?: string;
|
|
14
|
+
readonly defaultKeyResolver?: SecurityDefaultRateLimitKeyResolver;
|
|
15
|
+
}
|
|
16
|
+
interface SecurityClientConfig {
|
|
17
|
+
readonly csrf: {
|
|
18
|
+
readonly field: string;
|
|
19
|
+
readonly cookie: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
interface SecurityClientBindings {
|
|
23
|
+
readonly config?: {
|
|
24
|
+
readonly csrf?: Partial<SecurityClientConfig['csrf']>;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
interface SecurityCsrfField {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly value: string;
|
|
30
|
+
}
|
|
31
|
+
interface SecurityProtectOptions {
|
|
32
|
+
readonly csrf?: boolean;
|
|
33
|
+
readonly throttle?: string;
|
|
34
|
+
}
|
|
35
|
+
interface SecurityRateLimitCallOptions {
|
|
36
|
+
readonly request?: Request;
|
|
37
|
+
readonly key?: string;
|
|
38
|
+
readonly values?: Readonly<Record<string, unknown>>;
|
|
39
|
+
}
|
|
40
|
+
interface SecurityClearRateLimitOptions {
|
|
41
|
+
readonly limiter?: string;
|
|
42
|
+
readonly key?: string;
|
|
43
|
+
readonly all?: boolean;
|
|
44
|
+
}
|
|
45
|
+
interface SecurityCsrfFacade {
|
|
46
|
+
token(request: Request): Promise<string>;
|
|
47
|
+
field(request: Request): Promise<SecurityCsrfField>;
|
|
48
|
+
cookie(request: Request, token?: string): Promise<string>;
|
|
49
|
+
verify(request: Request): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
interface SecurityRateLimitBucketSnapshot {
|
|
52
|
+
readonly limiter: string;
|
|
53
|
+
readonly key: string;
|
|
54
|
+
readonly attempts: number;
|
|
55
|
+
readonly maxAttempts: number;
|
|
56
|
+
readonly remainingAttempts: number;
|
|
57
|
+
readonly expiresAt: Date;
|
|
58
|
+
}
|
|
59
|
+
interface SecurityRateLimitHitResult {
|
|
60
|
+
readonly limited: boolean;
|
|
61
|
+
readonly snapshot: SecurityRateLimitBucketSnapshot;
|
|
62
|
+
readonly retryAfterSeconds: number;
|
|
63
|
+
}
|
|
64
|
+
interface SecurityRateLimitStore {
|
|
65
|
+
hit(key: string, options: {
|
|
66
|
+
readonly maxAttempts: number;
|
|
67
|
+
readonly decaySeconds: number;
|
|
68
|
+
}): Promise<SecurityRateLimitHitResult>;
|
|
69
|
+
clear(key: string): Promise<boolean>;
|
|
70
|
+
clearByPrefix(prefix: string): Promise<number>;
|
|
71
|
+
clearAll(): Promise<number>;
|
|
72
|
+
close?(): Promise<void> | void;
|
|
73
|
+
}
|
|
74
|
+
interface SecurityDefaultRateLimitKeyResolver {
|
|
75
|
+
(request: Request): string | number | null | undefined | Promise<string | number | null | undefined>;
|
|
76
|
+
}
|
|
77
|
+
declare class SecurityCsrfError extends Error {
|
|
78
|
+
readonly status = 419;
|
|
79
|
+
constructor(message?: string);
|
|
80
|
+
}
|
|
81
|
+
declare class SecurityRateLimitError extends Error {
|
|
82
|
+
readonly status = 429;
|
|
83
|
+
readonly retryAfterSeconds?: number;
|
|
84
|
+
readonly snapshot?: SecurityRateLimitBucketSnapshot;
|
|
85
|
+
constructor(message?: string, options?: {
|
|
86
|
+
retryAfterSeconds?: number;
|
|
87
|
+
snapshot?: SecurityRateLimitBucketSnapshot;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
interface SecurityRateLimitRedisDriverAdapter {
|
|
91
|
+
connect?(): Promise<void>;
|
|
92
|
+
increment(key: string, options: {
|
|
93
|
+
readonly decaySeconds: number;
|
|
94
|
+
}): Promise<{
|
|
95
|
+
readonly attempts: number;
|
|
96
|
+
readonly ttlSeconds: number;
|
|
97
|
+
}>;
|
|
98
|
+
del(key: string): Promise<number>;
|
|
99
|
+
clearByPrefix?(prefix: string): Promise<number>;
|
|
100
|
+
clearAll?(): Promise<number>;
|
|
101
|
+
close?(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
interface SecurityRateLimitStoreFactoryOptions {
|
|
104
|
+
readonly projectRoot?: string;
|
|
105
|
+
readonly redisAdapter?: SecurityRateLimitRedisDriverAdapter;
|
|
106
|
+
}
|
|
107
|
+
declare class PendingSecurityLimiterDefinition<TValues extends Readonly<Record<string, unknown>> | undefined = Readonly<Record<string, unknown>> | undefined> {
|
|
108
|
+
readonly maxAttempts: number;
|
|
109
|
+
readonly decaySeconds: number;
|
|
110
|
+
constructor(maxAttempts: number, decaySeconds: number);
|
|
111
|
+
by(key: SecurityRateLimitKeyResolver<TValues>): SecurityLimiterConfig<TValues>;
|
|
112
|
+
define(): SecurityLimiterConfig<TValues>;
|
|
113
|
+
}
|
|
114
|
+
declare function normalizeLimiterAttempts(value: number, label: string): number;
|
|
115
|
+
declare function normalizeLimiterWindowSeconds(value: number, label: string): number;
|
|
116
|
+
declare const limit: Readonly<{
|
|
117
|
+
perMinute(maxAttempts: number): PendingSecurityLimiterDefinition<Readonly<Record<string, unknown>> | undefined>;
|
|
118
|
+
perHour(maxAttempts: number): PendingSecurityLimiterDefinition<Readonly<Record<string, unknown>> | undefined>;
|
|
119
|
+
}>;
|
|
120
|
+
declare function ip(request: Request, trustedProxy?: boolean): string;
|
|
121
|
+
declare function defineRateLimiter<TValues extends Readonly<Record<string, unknown>> | undefined = Readonly<Record<string, unknown>> | undefined>(definition: SecurityLimiterConfig<TValues>): SecurityLimiterConfig<TValues>;
|
|
122
|
+
declare function defineSecurityRuntimeBindings(bindings: SecurityRuntimeBindings): Readonly<{
|
|
123
|
+
config: NormalizedHoloSecurityConfig;
|
|
124
|
+
rateLimitStore?: SecurityRateLimitStore;
|
|
125
|
+
csrfSigningKey?: string;
|
|
126
|
+
defaultKeyResolver?: SecurityDefaultRateLimitKeyResolver;
|
|
127
|
+
}>;
|
|
128
|
+
declare function createMemoryRateLimitStoreConfig(config?: SecurityRateLimitMemoryConfig): Readonly<SecurityRateLimitMemoryConfig>;
|
|
129
|
+
declare function createFileRateLimitStoreConfig(config?: SecurityRateLimitFileConfig): Readonly<SecurityRateLimitFileConfig>;
|
|
130
|
+
declare function createRedisRateLimitStoreConfig(config?: SecurityRateLimitRedisConfig): Readonly<SecurityRateLimitRedisConfig>;
|
|
131
|
+
declare const securityInternals: {
|
|
132
|
+
PendingSecurityLimiterDefinition: typeof PendingSecurityLimiterDefinition;
|
|
133
|
+
normalizeLimiterAttempts: typeof normalizeLimiterAttempts;
|
|
134
|
+
normalizeLimiterWindowSeconds: typeof normalizeLimiterWindowSeconds;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export { type SecurityClearRateLimitOptions, type SecurityClientBindings, type SecurityClientConfig, SecurityCsrfError, type SecurityCsrfFacade, type SecurityCsrfField, type SecurityDefaultRateLimitKeyResolver, type SecurityProtectOptions, type SecurityRateLimitBucketSnapshot, type SecurityRateLimitCallOptions, SecurityRateLimitError, type SecurityRateLimitHitResult, type SecurityRateLimitRedisDriverAdapter, type SecurityRateLimitStore, type SecurityRateLimitStoreFactoryOptions, type SecurityRuntimeBindings, type SecurityRuntimeFacade, createFileRateLimitStoreConfig, createMemoryRateLimitStoreConfig, createRedisRateLimitStoreConfig, defineRateLimiter, defineSecurityRuntimeBindings, ip, limit, securityInternals };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SecurityCsrfError,
|
|
3
|
+
SecurityRateLimitError,
|
|
4
|
+
createFileRateLimitStoreConfig,
|
|
5
|
+
createMemoryRateLimitStoreConfig,
|
|
6
|
+
createRedisRateLimitStoreConfig,
|
|
7
|
+
defineRateLimiter,
|
|
8
|
+
defineSecurityRuntimeBindings,
|
|
9
|
+
ip,
|
|
10
|
+
limit,
|
|
11
|
+
securityInternals
|
|
12
|
+
} from "./chunk-3J5QRTPZ.mjs";
|
|
13
|
+
export {
|
|
14
|
+
SecurityCsrfError,
|
|
15
|
+
SecurityRateLimitError,
|
|
16
|
+
createFileRateLimitStoreConfig,
|
|
17
|
+
createMemoryRateLimitStoreConfig,
|
|
18
|
+
createRedisRateLimitStoreConfig,
|
|
19
|
+
defineRateLimiter,
|
|
20
|
+
defineSecurityRuntimeBindings,
|
|
21
|
+
ip,
|
|
22
|
+
limit,
|
|
23
|
+
securityInternals
|
|
24
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NormalizedSecurityRateLimitRedisConfig } from '@holo-js/config';
|
|
2
|
+
import { SecurityRateLimitRedisDriverAdapter } from '../contracts.js';
|
|
3
|
+
|
|
4
|
+
interface SecurityRedisAdapterOptions {
|
|
5
|
+
readonly now?: () => Date;
|
|
6
|
+
}
|
|
7
|
+
type RedisClientOptions = {
|
|
8
|
+
readonly host?: string;
|
|
9
|
+
readonly port?: number;
|
|
10
|
+
readonly path?: string;
|
|
11
|
+
readonly password?: string;
|
|
12
|
+
readonly username?: string;
|
|
13
|
+
readonly db?: number;
|
|
14
|
+
readonly connectionName?: string;
|
|
15
|
+
readonly lazyConnect: true;
|
|
16
|
+
readonly maxRetriesPerRequest: number;
|
|
17
|
+
};
|
|
18
|
+
type RedisClusterOptions = {
|
|
19
|
+
readonly redisOptions: RedisClientOptions;
|
|
20
|
+
};
|
|
21
|
+
type RedisClusterStartupNode = {
|
|
22
|
+
readonly host: string;
|
|
23
|
+
readonly port: number;
|
|
24
|
+
readonly tls?: Record<string, never>;
|
|
25
|
+
};
|
|
26
|
+
declare function isRedisConnectionTarget(value: string): boolean;
|
|
27
|
+
declare function isRedisSocketConnectionTarget(value: string): boolean;
|
|
28
|
+
declare function toRedisSocketPath(value: string): string;
|
|
29
|
+
declare function escapeRedisGlob(value: string): string;
|
|
30
|
+
declare function createRedisClientOptions(config: NormalizedSecurityRateLimitRedisConfig): RedisClientOptions;
|
|
31
|
+
declare function parseClusterNodeUrl(url: string, label: string): RedisClusterStartupNode;
|
|
32
|
+
declare function resolveClusterStartupNodes(config: NormalizedSecurityRateLimitRedisConfig): readonly RedisClusterStartupNode[];
|
|
33
|
+
declare function createRedisClusterOptions(config: NormalizedSecurityRateLimitRedisConfig): RedisClusterOptions;
|
|
34
|
+
declare class RedisSecurityAdapter implements SecurityRateLimitRedisDriverAdapter {
|
|
35
|
+
private readonly client;
|
|
36
|
+
private readonly now;
|
|
37
|
+
private readonly prefix;
|
|
38
|
+
constructor(config: NormalizedSecurityRateLimitRedisConfig, options?: SecurityRedisAdapterOptions);
|
|
39
|
+
private qualifyKey;
|
|
40
|
+
private qualifyPattern;
|
|
41
|
+
private normalizeScanResponse;
|
|
42
|
+
private clearMatchingKeys;
|
|
43
|
+
private parseOldestScore;
|
|
44
|
+
private getCommandValue;
|
|
45
|
+
connect(): Promise<void>;
|
|
46
|
+
increment(key: string, options: {
|
|
47
|
+
readonly decaySeconds: number;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
readonly attempts: number;
|
|
50
|
+
readonly ttlSeconds: number;
|
|
51
|
+
}>;
|
|
52
|
+
del(key: string): Promise<number>;
|
|
53
|
+
clearByPrefix(prefix: string): Promise<number>;
|
|
54
|
+
clearAll(): Promise<number>;
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
declare function createSecurityRedisAdapter(config: NormalizedSecurityRateLimitRedisConfig, options?: SecurityRedisAdapterOptions): RedisSecurityAdapter;
|
|
58
|
+
declare const securityRedisAdapterInternals: {
|
|
59
|
+
REDIS_SCAN_COUNT: number;
|
|
60
|
+
RedisSecurityAdapter: typeof RedisSecurityAdapter;
|
|
61
|
+
createRedisClientOptions: typeof createRedisClientOptions;
|
|
62
|
+
createRedisClusterOptions: typeof createRedisClusterOptions;
|
|
63
|
+
escapeRedisGlob: typeof escapeRedisGlob;
|
|
64
|
+
isRedisConnectionTarget: typeof isRedisConnectionTarget;
|
|
65
|
+
isRedisSocketConnectionTarget: typeof isRedisSocketConnectionTarget;
|
|
66
|
+
parseClusterNodeUrl: typeof parseClusterNodeUrl;
|
|
67
|
+
resolveClusterStartupNodes: typeof resolveClusterStartupNodes;
|
|
68
|
+
toRedisSocketPath: typeof toRedisSocketPath;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export { RedisSecurityAdapter, type SecurityRedisAdapterOptions, createSecurityRedisAdapter, securityRedisAdapterInternals };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// src/drivers/redis-adapter.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import Redis from "ioredis";
|
|
4
|
+
var REDIS_SCAN_COUNT = 100;
|
|
5
|
+
function isRedisConnectionTarget(value) {
|
|
6
|
+
return value.startsWith("redis://") || value.startsWith("rediss://") || value.startsWith("unix://") || value.startsWith("/");
|
|
7
|
+
}
|
|
8
|
+
function isRedisSocketConnectionTarget(value) {
|
|
9
|
+
return value.startsWith("unix://") || value.startsWith("/");
|
|
10
|
+
}
|
|
11
|
+
function toRedisSocketPath(value) {
|
|
12
|
+
return value.startsWith("unix://") ? value.slice("unix://".length) : value;
|
|
13
|
+
}
|
|
14
|
+
function escapeRedisGlob(value) {
|
|
15
|
+
return value.replace(/[\\*?[\]]/g, (match) => `\\${match}`);
|
|
16
|
+
}
|
|
17
|
+
function createRedisClientOptions(config) {
|
|
18
|
+
return {
|
|
19
|
+
password: config.password,
|
|
20
|
+
username: config.username,
|
|
21
|
+
db: config.db,
|
|
22
|
+
...typeof config.url === "undefined" && !isRedisConnectionTarget(config.connection) && !config.clusters?.length && !isRedisSocketConnectionTarget(config.host) ? {
|
|
23
|
+
host: config.host,
|
|
24
|
+
port: config.port
|
|
25
|
+
} : typeof config.url === "undefined" && isRedisSocketConnectionTarget(config.host) && !config.clusters?.length ? { path: toRedisSocketPath(config.host) } : {},
|
|
26
|
+
...typeof config.url === "undefined" && config.connection !== "default" && !isRedisConnectionTarget(config.connection) && !config.clusters?.length ? { connectionName: config.connection } : {},
|
|
27
|
+
lazyConnect: true,
|
|
28
|
+
maxRetriesPerRequest: 3
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function parseClusterNodeUrl(url, label) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = new URL(url);
|
|
34
|
+
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
|
|
35
|
+
throw new Error(`unsupported protocol "${parsed.protocol}"`);
|
|
36
|
+
}
|
|
37
|
+
if (!parsed.hostname) {
|
|
38
|
+
throw new Error("missing hostname");
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
host: parsed.hostname,
|
|
42
|
+
port: parsed.port ? Number.parseInt(parsed.port, 10) : 6379,
|
|
43
|
+
...parsed.protocol === "rediss:" ? { tls: {} } : {}
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(`[@holo-js/security] ${label} is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function resolveClusterStartupNodes(config) {
|
|
50
|
+
return (config.clusters ?? []).map((node, index) => {
|
|
51
|
+
const label = `Security rate-limit Redis cluster node ${index + 1}`;
|
|
52
|
+
if (typeof node.url === "string") {
|
|
53
|
+
return parseClusterNodeUrl(node.url, `${label} url`);
|
|
54
|
+
}
|
|
55
|
+
if (isRedisSocketConnectionTarget(node.host)) {
|
|
56
|
+
throw new Error(`[@holo-js/security] ${label} cannot use a Unix socket path in Redis cluster mode.`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
host: node.host,
|
|
60
|
+
port: node.port
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function createRedisClusterOptions(config) {
|
|
65
|
+
if (typeof config.db === "number" && config.db !== 0) {
|
|
66
|
+
throw new Error("[@holo-js/security] Redis Cluster does not support selecting a non-zero database. Remove redis.db or set it to 0.");
|
|
67
|
+
}
|
|
68
|
+
const startupNodes = resolveClusterStartupNodes(config);
|
|
69
|
+
return {
|
|
70
|
+
redisOptions: {
|
|
71
|
+
password: config.password,
|
|
72
|
+
username: config.username,
|
|
73
|
+
lazyConnect: true,
|
|
74
|
+
maxRetriesPerRequest: 3,
|
|
75
|
+
...startupNodes.some((node) => typeof node.tls !== "undefined") ? { tls: {} } : {}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
var RedisSecurityAdapter = class {
|
|
80
|
+
client;
|
|
81
|
+
now;
|
|
82
|
+
prefix;
|
|
83
|
+
constructor(config, options = {}) {
|
|
84
|
+
this.prefix = config.prefix;
|
|
85
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
86
|
+
const clientOptions = createRedisClientOptions(config);
|
|
87
|
+
const RedisConstructor = Redis;
|
|
88
|
+
this.client = typeof config.url === "string" ? new RedisConstructor(config.url, clientOptions) : config.clusters && config.clusters.length > 0 ? new RedisConstructor.Cluster(resolveClusterStartupNodes(config), createRedisClusterOptions(config)) : isRedisConnectionTarget(config.connection) ? new RedisConstructor(config.connection, clientOptions) : new RedisConstructor(clientOptions);
|
|
89
|
+
}
|
|
90
|
+
qualifyKey(key) {
|
|
91
|
+
return `${this.prefix}${key}`;
|
|
92
|
+
}
|
|
93
|
+
qualifyPattern(pattern) {
|
|
94
|
+
return `${escapeRedisGlob(this.prefix)}${pattern}`;
|
|
95
|
+
}
|
|
96
|
+
normalizeScanResponse(result) {
|
|
97
|
+
if (!Array.isArray(result) || result.length < 2) {
|
|
98
|
+
throw new Error("[@holo-js/security] Redis returned an invalid scan response while clearing rate-limit buckets.");
|
|
99
|
+
}
|
|
100
|
+
const [cursor, keys] = result;
|
|
101
|
+
if (typeof cursor !== "string" || !Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
|
|
102
|
+
throw new Error("[@holo-js/security] Redis returned an invalid scan response while clearing rate-limit buckets.");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
cursor,
|
|
106
|
+
keys
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async clearMatchingKeys(pattern) {
|
|
110
|
+
let cursor = "0";
|
|
111
|
+
let cleared = 0;
|
|
112
|
+
do {
|
|
113
|
+
const page = this.normalizeScanResponse(await this.client.scan(
|
|
114
|
+
cursor,
|
|
115
|
+
"MATCH",
|
|
116
|
+
pattern,
|
|
117
|
+
"COUNT",
|
|
118
|
+
REDIS_SCAN_COUNT
|
|
119
|
+
));
|
|
120
|
+
cursor = page.cursor;
|
|
121
|
+
if (page.keys.length > 0) {
|
|
122
|
+
cleared += await this.client.del(...page.keys);
|
|
123
|
+
}
|
|
124
|
+
} while (cursor !== "0");
|
|
125
|
+
return cleared;
|
|
126
|
+
}
|
|
127
|
+
parseOldestScore(result) {
|
|
128
|
+
if (!Array.isArray(result) || result.length < 2) {
|
|
129
|
+
throw new Error("[@holo-js/security] Redis transaction failed to return the oldest rate-limit hit.");
|
|
130
|
+
}
|
|
131
|
+
const value = result[1];
|
|
132
|
+
const score = typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number.NaN;
|
|
133
|
+
if (!Number.isFinite(score)) {
|
|
134
|
+
throw new Error("[@holo-js/security] Redis transaction returned an invalid oldest-hit score.");
|
|
135
|
+
}
|
|
136
|
+
return score;
|
|
137
|
+
}
|
|
138
|
+
getCommandValue(result, index, operation) {
|
|
139
|
+
const entry = result[index];
|
|
140
|
+
if (!entry) {
|
|
141
|
+
throw new Error(`[@holo-js/security] Redis transaction failed to return the ${operation}.`);
|
|
142
|
+
}
|
|
143
|
+
const [error, value] = entry;
|
|
144
|
+
if (error instanceof Error) {
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
if (error) {
|
|
148
|
+
throw new Error(`[@holo-js/security] Redis transaction failed for ${operation}: ${String(error)}`);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
async connect() {
|
|
153
|
+
await this.client.connect();
|
|
154
|
+
}
|
|
155
|
+
async increment(key, options) {
|
|
156
|
+
const now = this.now().getTime();
|
|
157
|
+
const ttlMs = options.decaySeconds * 1e3;
|
|
158
|
+
const qualifiedKey = this.qualifyKey(key);
|
|
159
|
+
const member = `${now}:${randomUUID()}`;
|
|
160
|
+
const result = await this.client.multi().zadd(qualifiedKey, now, member).zremrangebyscore(qualifiedKey, "-inf", now - ttlMs).zcard(qualifiedKey).zrange(qualifiedKey, 0, 0, "WITHSCORES").exec();
|
|
161
|
+
if (!result) {
|
|
162
|
+
throw new Error("[@holo-js/security] Redis transaction failed for increment.");
|
|
163
|
+
}
|
|
164
|
+
const attemptsValue = this.getCommandValue(result, 2, "attempt count");
|
|
165
|
+
if (typeof attemptsValue !== "number") {
|
|
166
|
+
throw new Error("[@holo-js/security] Redis transaction returned an invalid attempt count.");
|
|
167
|
+
}
|
|
168
|
+
const attempts = attemptsValue;
|
|
169
|
+
const oldestScore = this.parseOldestScore(this.getCommandValue(result, 3, "oldest rate-limit hit"));
|
|
170
|
+
await this.client.pexpireat(qualifiedKey, now + ttlMs);
|
|
171
|
+
const ttlSeconds = Math.max(0, Math.ceil((oldestScore + ttlMs - now) / 1e3));
|
|
172
|
+
return { attempts, ttlSeconds };
|
|
173
|
+
}
|
|
174
|
+
async del(key) {
|
|
175
|
+
return this.client.del(this.qualifyKey(key));
|
|
176
|
+
}
|
|
177
|
+
async clearByPrefix(prefix) {
|
|
178
|
+
const basePrefix = prefix.endsWith("*") ? prefix.slice(0, -1) : prefix;
|
|
179
|
+
const pattern = this.qualifyPattern(`${escapeRedisGlob(basePrefix)}*`);
|
|
180
|
+
return await this.clearMatchingKeys(pattern);
|
|
181
|
+
}
|
|
182
|
+
async clearAll() {
|
|
183
|
+
return await this.clearMatchingKeys(this.qualifyPattern("*"));
|
|
184
|
+
}
|
|
185
|
+
async close() {
|
|
186
|
+
try {
|
|
187
|
+
await this.client.quit();
|
|
188
|
+
} catch {
|
|
189
|
+
this.client.disconnect();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
function createSecurityRedisAdapter(config, options) {
|
|
194
|
+
return new RedisSecurityAdapter(config, options);
|
|
195
|
+
}
|
|
196
|
+
var securityRedisAdapterInternals = {
|
|
197
|
+
REDIS_SCAN_COUNT,
|
|
198
|
+
RedisSecurityAdapter,
|
|
199
|
+
createRedisClientOptions,
|
|
200
|
+
createRedisClusterOptions,
|
|
201
|
+
escapeRedisGlob,
|
|
202
|
+
isRedisConnectionTarget,
|
|
203
|
+
isRedisSocketConnectionTarget,
|
|
204
|
+
parseClusterNodeUrl,
|
|
205
|
+
resolveClusterStartupNodes,
|
|
206
|
+
toRedisSocketPath
|
|
207
|
+
};
|
|
208
|
+
export {
|
|
209
|
+
RedisSecurityAdapter,
|
|
210
|
+
createSecurityRedisAdapter,
|
|
211
|
+
securityRedisAdapterInternals
|
|
212
|
+
};
|