@holo-js/security 0.3.27 → 0.4.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/LICENSE +21 -0
- package/dist/.holo-build-fingerprint +1 -0
- package/dist/{chunk-WRMZRO77.mjs → chunk-D3ZN5MLJ.mjs} +112 -6
- package/dist/{chunk-PC2YCWDH.mjs → chunk-EINZXLC2.mjs} +7 -5
- package/dist/chunk-Q3PK4AGL.mjs +84 -0
- package/dist/{chunk-5SCCUNBO.mjs → chunk-RYQLS2OO.mjs} +429 -219
- package/dist/{chunk-FQ4X7X6L.mjs → chunk-U25HSHLJ.mjs} +4 -16
- package/dist/client.d.ts +1 -1
- package/dist/client.mjs +2 -2
- package/dist/config-BK6q6Yh9.d.ts +145 -0
- package/dist/config.d.ts +2 -136
- package/dist/config.mjs +1 -1
- package/dist/contracts.d.ts +15 -4
- package/dist/contracts.mjs +2 -4
- package/dist/drivers/redis-adapter.d.ts +3 -8
- package/dist/drivers/redis-adapter.mjs +13 -29
- package/dist/index.d.ts +24 -76
- package/dist/index.mjs +5 -6
- package/dist/next/server.d.ts +2 -1
- package/dist/next/server.mjs +34 -23
- package/dist/nuxt/server.mjs +53 -18
- package/dist/sveltekit/server.d.ts +3 -2
- package/dist/sveltekit/server.mjs +15 -20
- package/package.json +19 -13
- package/dist/chunk-F32JK536.mjs +0 -45
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Holo-JS contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3659fb6c9af3ebac67b4d8337c00b81aa27f5427378340742f731a12f964746e
|
|
@@ -7,9 +7,97 @@ import {
|
|
|
7
7
|
resolveNormalizedRedisConnection
|
|
8
8
|
} from "@holo-js/kernel";
|
|
9
9
|
import { registerConfigNormalizer } from "@holo-js/config/registry";
|
|
10
|
+
import { DEFAULT_MAX_REQUEST_BODY_BYTES, DEFAULT_MAX_UPLOAD_BODY_BYTES } from "@holo-js/kernel";
|
|
11
|
+
|
|
12
|
+
// src/trusted-proxies.ts
|
|
13
|
+
import { BlockList, isIP } from "net";
|
|
14
|
+
var ipv4MappedPrefix = "::ffff:";
|
|
15
|
+
function canonicalAddress(address) {
|
|
16
|
+
const lowered = address.toLowerCase();
|
|
17
|
+
if (lowered.startsWith(ipv4MappedPrefix) && isIP(lowered.slice(ipv4MappedPrefix.length)) === 4) {
|
|
18
|
+
return lowered.slice(ipv4MappedPrefix.length);
|
|
19
|
+
}
|
|
20
|
+
return address;
|
|
21
|
+
}
|
|
22
|
+
function bareAddress(value) {
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (isIP(trimmed)) return canonicalAddress(trimmed);
|
|
25
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/u.exec(trimmed)?.[1];
|
|
26
|
+
if (bracketed && isIP(bracketed) === 6) return canonicalAddress(bracketed);
|
|
27
|
+
const ipv4 = /^([\d.]+):\d+$/u.exec(trimmed)?.[1];
|
|
28
|
+
return ipv4 && isIP(ipv4) === 4 ? ipv4 : void 0;
|
|
29
|
+
}
|
|
30
|
+
function parseRange(entry) {
|
|
31
|
+
const [address = "", prefix, ...rest] = entry.trim().split("/");
|
|
32
|
+
const family = isIP(address);
|
|
33
|
+
if (!family || rest.length > 0) return void 0;
|
|
34
|
+
if (prefix === void 0) return { address };
|
|
35
|
+
if (!/^\d+$/u.test(prefix)) return void 0;
|
|
36
|
+
const bits = Number(prefix);
|
|
37
|
+
return bits <= (family === 4 ? 32 : 128) ? { address, prefix: bits } : void 0;
|
|
38
|
+
}
|
|
39
|
+
function normalizeTrustedProxies(value) {
|
|
40
|
+
if (value === void 0) return Object.freeze([]);
|
|
41
|
+
if (value === "*") return value;
|
|
42
|
+
return Object.freeze(value.map((entry) => {
|
|
43
|
+
if (!parseRange(entry)) {
|
|
44
|
+
throw new TypeError(`[@holo-js/security] trustedProxies entries must be IP addresses or CIDR ranges, received "${entry}".`);
|
|
45
|
+
}
|
|
46
|
+
return entry.trim();
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
function createTrustedAddressMatcher(trustedProxies, connectionAddress) {
|
|
50
|
+
const entries = trustedProxies !== "*" ? trustedProxies : connectionAddress && isIP(connectionAddress) ? [connectionAddress] : [];
|
|
51
|
+
const ranges = new BlockList();
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const range = parseRange(entry);
|
|
54
|
+
if (!range) continue;
|
|
55
|
+
const family = isIP(range.address) === 4 ? "ipv4" : "ipv6";
|
|
56
|
+
if (range.prefix === void 0) ranges.addAddress(range.address, family);
|
|
57
|
+
else ranges.addSubnet(range.address, range.prefix, family);
|
|
58
|
+
}
|
|
59
|
+
return (address) => ranges.check(address, isIP(address) === 4 ? "ipv4" : "ipv6");
|
|
60
|
+
}
|
|
61
|
+
var secureProtocols = /* @__PURE__ */ new Set(["https", "on", "ssl", "1"]);
|
|
62
|
+
function firstListValue(value) {
|
|
63
|
+
return value?.split(",")[0]?.trim() || void 0;
|
|
64
|
+
}
|
|
65
|
+
function forwardedProtocol(value) {
|
|
66
|
+
if (value === void 0) return void 0;
|
|
67
|
+
return secureProtocols.has(value.toLowerCase()) ? "https" : "http";
|
|
68
|
+
}
|
|
69
|
+
function clientAddress(forwardedFor, connectionAddress, isTrusted) {
|
|
70
|
+
let client;
|
|
71
|
+
let firstTrusted;
|
|
72
|
+
for (const entry of [...forwardedFor?.split(",") ?? [], ...connectionAddress === void 0 ? [] : [connectionAddress]]) {
|
|
73
|
+
const address = bareAddress(entry);
|
|
74
|
+
if (!address) continue;
|
|
75
|
+
if (isTrusted(address)) firstTrusted ??= address;
|
|
76
|
+
else client = address;
|
|
77
|
+
}
|
|
78
|
+
return client ?? firstTrusted;
|
|
79
|
+
}
|
|
80
|
+
function resolveForwarding(readHeader, rawConnectionAddress, trustedProxies) {
|
|
81
|
+
const connectionAddress = rawConnectionAddress === void 0 ? void 0 : bareAddress(rawConnectionAddress) ?? rawConnectionAddress;
|
|
82
|
+
const isTrusted = createTrustedAddressMatcher(trustedProxies, connectionAddress);
|
|
83
|
+
const fromTrustedProxy = connectionAddress === void 0 ? trustedProxies === "*" : isTrusted(connectionAddress);
|
|
84
|
+
if (!fromTrustedProxy) return { address: connectionAddress };
|
|
85
|
+
return {
|
|
86
|
+
address: clientAddress(readHeader("x-forwarded-for"), connectionAddress, isTrusted),
|
|
87
|
+
protocol: forwardedProtocol(firstListValue(readHeader("x-forwarded-proto"))),
|
|
88
|
+
host: firstListValue(readHeader("x-forwarded-host")),
|
|
89
|
+
port: firstListValue(readHeader("x-forwarded-port")),
|
|
90
|
+
prefix: firstListValue(readHeader("x-forwarded-prefix"))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function createForwardedRequestResolver(trustedProxies) {
|
|
94
|
+
return (readHeader, connectionAddress) => resolveForwarding(readHeader, connectionAddress, trustedProxies);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/config.ts
|
|
10
98
|
var DEFAULT_CORS_PATHS = Object.freeze(["/api/*"]);
|
|
11
99
|
var DEFAULT_CORS_METHODS = Object.freeze(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
|
|
12
|
-
var DEFAULT_CORS_HEADERS = Object.freeze(["Content-Type", "Authorization", "X-CSRF-TOKEN", "X-Requested-With"]);
|
|
100
|
+
var DEFAULT_CORS_HEADERS = Object.freeze(["Content-Type", "Authorization", "X-CSRF-TOKEN", "X-XSRF-TOKEN", "X-Requested-With"]);
|
|
13
101
|
var DEFAULT_CORS_MAX_AGE = 7200;
|
|
14
102
|
var holoCorsDefaults = Object.freeze({
|
|
15
103
|
paths: DEFAULT_CORS_PATHS,
|
|
@@ -28,7 +116,7 @@ var DEFAULT_SECURITY_RATE_LIMIT_FILE_PATH = "./storage/framework/rate-limits";
|
|
|
28
116
|
var DEFAULT_SECURITY_RATE_LIMIT_REDIS_CONNECTION = "default";
|
|
29
117
|
var DEFAULT_SECURITY_RATE_LIMIT_REDIS_PREFIX = "holo:rate-limit:";
|
|
30
118
|
var DEFAULT_SECURITY_CSRF_CONFIG = Object.freeze({
|
|
31
|
-
enabled:
|
|
119
|
+
enabled: true,
|
|
32
120
|
field: DEFAULT_SECURITY_CSRF_FIELD,
|
|
33
121
|
header: DEFAULT_SECURITY_CSRF_HEADER,
|
|
34
122
|
cookie: DEFAULT_SECURITY_CSRF_COOKIE,
|
|
@@ -55,7 +143,10 @@ var DEFAULT_SECURITY_RATE_LIMIT_CONFIG = Object.freeze({
|
|
|
55
143
|
});
|
|
56
144
|
var holoSecurityDefaults = Object.freeze({
|
|
57
145
|
csrf: DEFAULT_SECURITY_CSRF_CONFIG,
|
|
58
|
-
rateLimit: DEFAULT_SECURITY_RATE_LIMIT_CONFIG
|
|
146
|
+
rateLimit: DEFAULT_SECURITY_RATE_LIMIT_CONFIG,
|
|
147
|
+
trustedProxies: normalizeTrustedProxies(void 0),
|
|
148
|
+
maxBodyBytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
|
|
149
|
+
maxUploadBodyBytes: DEFAULT_MAX_UPLOAD_BODY_BYTES
|
|
59
150
|
});
|
|
60
151
|
function parseScopedInteger(value, fallback, namespace, label, options = {}) {
|
|
61
152
|
const normalized = typeof value === "undefined" ? fallback : typeof value === "number" ? value : value.trim() ? Number(value.trim()) : Number.NaN;
|
|
@@ -99,12 +190,17 @@ function normalizeCorsMaxAge(value) {
|
|
|
99
190
|
});
|
|
100
191
|
}
|
|
101
192
|
function normalizeCorsConfig(config = {}) {
|
|
193
|
+
const origins = normalizeCorsStringList(config.origins, holoCorsDefaults.origins, "origins");
|
|
194
|
+
const credentials = config.credentials ?? holoCorsDefaults.credentials;
|
|
195
|
+
if (credentials && origins.includes("*")) {
|
|
196
|
+
throw new Error("[Holo CORS] Wildcard origins cannot be combined with credentials.");
|
|
197
|
+
}
|
|
102
198
|
return Object.freeze({
|
|
103
199
|
paths: normalizeCorsStringList(config.paths, DEFAULT_CORS_PATHS, "paths"),
|
|
104
|
-
origins
|
|
200
|
+
origins,
|
|
105
201
|
methods: normalizeCorsMethods(config.methods),
|
|
106
202
|
headers: normalizeCorsStringList(config.headers, DEFAULT_CORS_HEADERS, "headers"),
|
|
107
|
-
credentials
|
|
203
|
+
credentials,
|
|
108
204
|
maxAge: normalizeCorsMaxAge(config.maxAge),
|
|
109
205
|
statefulDomains: normalizeCorsStringList(config.statefulDomains, holoCorsDefaults.statefulDomains, "statefulDomains")
|
|
110
206
|
});
|
|
@@ -197,7 +293,14 @@ function normalizeSecurityConfig(config = {}, redisConfig) {
|
|
|
197
293
|
cookie: normalizeSecurityOptionalString(csrf.cookie) || DEFAULT_SECURITY_CSRF_COOKIE,
|
|
198
294
|
except
|
|
199
295
|
}),
|
|
200
|
-
rateLimit: normalizeSecurityRateLimitConfig(config.rateLimit, redisConfig)
|
|
296
|
+
rateLimit: normalizeSecurityRateLimitConfig(config.rateLimit, redisConfig),
|
|
297
|
+
trustedProxies: normalizeTrustedProxies(config.trustedProxies),
|
|
298
|
+
maxBodyBytes: parseSecurityInteger(config.maxBodyBytes, DEFAULT_MAX_REQUEST_BODY_BYTES, "maxBodyBytes", {
|
|
299
|
+
minimum: 1
|
|
300
|
+
}),
|
|
301
|
+
maxUploadBodyBytes: parseSecurityInteger(config.maxUploadBodyBytes, DEFAULT_MAX_UPLOAD_BODY_BYTES, "maxUploadBodyBytes", {
|
|
302
|
+
minimum: 1
|
|
303
|
+
})
|
|
201
304
|
});
|
|
202
305
|
}
|
|
203
306
|
function defineSecurityConfig(config) {
|
|
@@ -219,6 +322,9 @@ registerConfigNormalizer({
|
|
|
219
322
|
});
|
|
220
323
|
|
|
221
324
|
export {
|
|
325
|
+
normalizeTrustedProxies,
|
|
326
|
+
resolveForwarding,
|
|
327
|
+
createForwardedRequestResolver,
|
|
222
328
|
DEFAULT_CORS_PATHS,
|
|
223
329
|
DEFAULT_CORS_METHODS,
|
|
224
330
|
DEFAULT_CORS_HEADERS,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// src/client-config.ts
|
|
2
2
|
var SECURITY_CLIENT_CONFIG_COOKIE = "HOLO-CSRF-CONFIG";
|
|
3
|
+
var SECURITY_XSRF_HEADER = "X-XSRF-TOKEN";
|
|
3
4
|
var DEFAULT_SECURITY_CLIENT_CONFIG = Object.freeze({
|
|
4
5
|
csrf: Object.freeze({
|
|
5
6
|
field: "_token",
|
|
7
|
+
header: "X-CSRF-TOKEN",
|
|
6
8
|
cookie: "XSRF-TOKEN"
|
|
7
9
|
})
|
|
8
10
|
});
|
|
@@ -29,7 +31,7 @@ function parseCookieHeader(header) {
|
|
|
29
31
|
return Object.freeze(Object.fromEntries(entries));
|
|
30
32
|
}
|
|
31
33
|
function isSecurityClientConfig(value) {
|
|
32
|
-
return !!value && typeof value === "object" && !!value.csrf && typeof value.csrf.field === "string" && typeof value.csrf.cookie === "string";
|
|
34
|
+
return !!value && typeof value === "object" && !!value.csrf && typeof value.csrf.field === "string" && ["string", "undefined"].includes(typeof value.csrf.header) && typeof value.csrf.cookie === "string";
|
|
33
35
|
}
|
|
34
36
|
function getDefaultSecurityClientConfig() {
|
|
35
37
|
return DEFAULT_SECURITY_CLIENT_CONFIG;
|
|
@@ -65,8 +67,8 @@ function readSecurityClientConfigFromCookies(cookieHeader) {
|
|
|
65
67
|
return parsed ? Object.freeze({
|
|
66
68
|
csrf: Object.freeze({
|
|
67
69
|
field: parsed.csrf.field,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
header: parsed.csrf.header ?? DEFAULT_SECURITY_CLIENT_CONFIG.csrf.header,
|
|
71
|
+
cookie: parsed.csrf.cookie
|
|
70
72
|
})
|
|
71
73
|
}) : void 0;
|
|
72
74
|
}
|
|
@@ -74,8 +76,7 @@ function getCsrfRequestHeaders(cookieHeader) {
|
|
|
74
76
|
const cookies = parseCookieHeader(cookieHeader);
|
|
75
77
|
const config = parseSecurityClientConfig(cookies[SECURITY_CLIENT_CONFIG_COOKIE]) ?? DEFAULT_SECURITY_CLIENT_CONFIG;
|
|
76
78
|
const token = cookies[config.csrf.cookie];
|
|
77
|
-
|
|
78
|
-
return token ? { [header]: token } : {};
|
|
79
|
+
return token ? { [SECURITY_XSRF_HEADER]: token } : {};
|
|
79
80
|
}
|
|
80
81
|
var securityClientConfigInternals = {
|
|
81
82
|
parseCookieHeader,
|
|
@@ -84,6 +85,7 @@ var securityClientConfigInternals = {
|
|
|
84
85
|
|
|
85
86
|
export {
|
|
86
87
|
SECURITY_CLIENT_CONFIG_COOKIE,
|
|
88
|
+
SECURITY_XSRF_HEADER,
|
|
87
89
|
getDefaultSecurityClientConfig,
|
|
88
90
|
createSecurityClientConfig,
|
|
89
91
|
serializeSecurityClientConfig,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
csrfInternals,
|
|
3
|
+
encryptedToken,
|
|
4
|
+
getSecurityRuntime,
|
|
5
|
+
isSecureRequest
|
|
6
|
+
} from "./chunk-RYQLS2OO.mjs";
|
|
7
|
+
import {
|
|
8
|
+
SECURITY_CLIENT_CONFIG_COOKIE,
|
|
9
|
+
createSecurityClientConfig,
|
|
10
|
+
serializeSecurityClientConfig
|
|
11
|
+
} from "./chunk-EINZXLC2.mjs";
|
|
12
|
+
import {
|
|
13
|
+
SecurityCsrfError
|
|
14
|
+
} from "./chunk-U25HSHLJ.mjs";
|
|
15
|
+
|
|
16
|
+
// src/cookie-issuance.ts
|
|
17
|
+
var NO_CSRF_COOKIES = Object.freeze({ cookies: Object.freeze([]), sessionCookies: Object.freeze([]) });
|
|
18
|
+
async function prepareCsrfCookies(request, getCookie) {
|
|
19
|
+
const { config } = getSecurityRuntime();
|
|
20
|
+
const method = request.method.trim().toUpperCase();
|
|
21
|
+
if (!config.csrf.enabled || method !== "GET" && method !== "HEAD") {
|
|
22
|
+
return NO_CSRF_COOKIES;
|
|
23
|
+
}
|
|
24
|
+
const { token, sessionCookies } = await csrfInternals.ensureSessionToken(request);
|
|
25
|
+
const existingCookie = getCookie(config.csrf.cookie);
|
|
26
|
+
const clientConfig = serializeSecurityClientConfig(createSecurityClientConfig(config));
|
|
27
|
+
const shouldIssueToken = !existingCookie || csrfInternals.decryptCsrfToken(existingCookie) !== token;
|
|
28
|
+
const shouldIssueClientConfig = getCookie(SECURITY_CLIENT_CONFIG_COOKIE) !== clientConfig;
|
|
29
|
+
const options = {
|
|
30
|
+
httpOnly: false,
|
|
31
|
+
path: "/",
|
|
32
|
+
sameSite: "lax",
|
|
33
|
+
secure: isSecureRequest(request)
|
|
34
|
+
};
|
|
35
|
+
const cookies = [];
|
|
36
|
+
if (shouldIssueToken) {
|
|
37
|
+
cookies.push({ name: config.csrf.cookie, value: await encryptedToken(request, token), options });
|
|
38
|
+
}
|
|
39
|
+
if (shouldIssueClientConfig) {
|
|
40
|
+
cookies.push({ name: SECURITY_CLIENT_CONFIG_COOKIE, value: clientConfig, options });
|
|
41
|
+
}
|
|
42
|
+
return { token, cookies, sessionCookies };
|
|
43
|
+
}
|
|
44
|
+
function appendSetCookies(response, setCookies) {
|
|
45
|
+
if (setCookies.length === 0) {
|
|
46
|
+
return response;
|
|
47
|
+
}
|
|
48
|
+
const headers = new Headers();
|
|
49
|
+
for (const setCookie of setCookies) {
|
|
50
|
+
headers.append("Set-Cookie", setCookie);
|
|
51
|
+
}
|
|
52
|
+
response.headers.forEach((value, name) => {
|
|
53
|
+
if (name !== "set-cookie") headers.append(name, value);
|
|
54
|
+
});
|
|
55
|
+
for (const setCookie of response.headers.getSetCookie()) {
|
|
56
|
+
headers.append("Set-Cookie", setCookie);
|
|
57
|
+
}
|
|
58
|
+
return new Response(response.body, {
|
|
59
|
+
status: response.status,
|
|
60
|
+
statusText: response.statusText,
|
|
61
|
+
headers
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/rejection-response.ts
|
|
66
|
+
import { HoloRequestBodyTooLargeError, HoloRequestUrlError } from "@holo-js/kernel";
|
|
67
|
+
function isSecurityRejection(error) {
|
|
68
|
+
return error instanceof SecurityCsrfError || error instanceof HoloRequestUrlError || error instanceof HoloRequestBodyTooLargeError;
|
|
69
|
+
}
|
|
70
|
+
function createSecurityRejectionResponse(error) {
|
|
71
|
+
return new Response(error.message, {
|
|
72
|
+
status: error.status,
|
|
73
|
+
headers: {
|
|
74
|
+
"content-type": "text/plain; charset=utf-8"
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
prepareCsrfCookies,
|
|
81
|
+
appendSetCookies,
|
|
82
|
+
isSecurityRejection,
|
|
83
|
+
createSecurityRejectionResponse
|
|
84
|
+
};
|