@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 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: false,
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: normalizeCorsStringList(config.origins, holoCorsDefaults.origins, "origins"),
200
+ origins,
105
201
  methods: normalizeCorsMethods(config.methods),
106
202
  headers: normalizeCorsStringList(config.headers, DEFAULT_CORS_HEADERS, "headers"),
107
- credentials: config.credentials ?? holoCorsDefaults.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
- cookie: parsed.csrf.cookie,
69
- ..."header" in parsed.csrf && typeof parsed.csrf.header === "string" ? { header: parsed.csrf.header } : {}
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
- const header = "header" in config.csrf && typeof config.csrf.header === "string" ? config.csrf.header : "X-CSRF-TOKEN";
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
+ };