shokupan 0.6.0 → 0.6.1

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { Eta } from "eta";
3
3
  import { stat, readdir, readFile as readFile$1 } from "fs/promises";
4
- import { resolve, join, basename } from "path";
4
+ import { resolve, join, sep, basename } from "path";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { trace, SpanKind, SpanStatusCode, context } from "@opentelemetry/api";
7
7
  import * as os from "node:os";
@@ -76,6 +76,15 @@ class ShokupanResponse {
76
76
  return this._headers !== null;
77
77
  }
78
78
  }
79
+ function isValidCookieDomain(domain, currentHost) {
80
+ const hostWithoutPort = currentHost.split(":")[0];
81
+ if (domain === hostWithoutPort) return true;
82
+ if (domain.startsWith(".")) {
83
+ const domainWithoutDot = domain.slice(1);
84
+ return hostWithoutPort.endsWith(domainWithoutDot);
85
+ }
86
+ return false;
87
+ }
79
88
  const VALID_HTTP_STATUSES = /* @__PURE__ */ new Set([
80
89
  100,
81
90
  101,
@@ -235,16 +244,20 @@ class ShokupanContext {
235
244
  */
236
245
  get query() {
237
246
  if (this._cachedQuery) return this._cachedQuery;
238
- const q = {};
247
+ const q = /* @__PURE__ */ Object.create(null);
248
+ const blocklist = ["__proto__", "constructor", "prototype"];
239
249
  const entries = Object.entries(this.url.searchParams);
240
250
  for (let i = 0; i < entries.length; i++) {
241
251
  const [key, value] = entries[i];
242
- if (q[key] === void 0) {
243
- q[key] = value;
244
- } else if (Array.isArray(q[key])) {
245
- q[key].push(value);
252
+ if (blocklist.includes(key)) continue;
253
+ if (Object.prototype.hasOwnProperty.call(q, key)) {
254
+ if (Array.isArray(q[key])) {
255
+ q[key].push(value);
256
+ } else {
257
+ q[key] = [q[key], value];
258
+ }
246
259
  } else {
247
- q[key] = [q[key], value];
260
+ q[key] = value;
248
261
  }
249
262
  }
250
263
  this._cachedQuery = q;
@@ -321,6 +334,12 @@ class ShokupanContext {
321
334
  * @param options Cookie options
322
335
  */
323
336
  setCookie(name, value, options = {}) {
337
+ if (options.domain) {
338
+ const currentHost = this.hostname;
339
+ if (!isValidCookieDomain(options.domain, currentHost)) {
340
+ throw new Error(`Invalid cookie domain: ${options.domain} for host ${currentHost}`);
341
+ }
342
+ }
324
343
  let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
325
344
  if (options.maxAge) cookie += `; Max-Age=${Math.floor(options.maxAge)}`;
326
345
  if (options.domain) cookie += `; Domain=${options.domain}`;
@@ -614,11 +633,24 @@ function RateLimitMiddleware(options = {}) {
614
633
  const statusCode = options.statusCode || 429;
615
634
  const headers = options.headers !== false;
616
635
  const mode = options.mode || "user";
636
+ const trustedProxies = options.trustedProxies || [];
617
637
  const keyGenerator = options.keyGenerator || ((ctx) => {
618
638
  if (mode === "absolute") {
619
639
  return "global";
620
640
  }
621
- return ctx.headers.get("x-forwarded-for") || ctx.request.headers.get("x-forwarded-for") || ctx.server?.requestIP?.(ctx.request)?.address || "unknown";
641
+ const xForwardedFor = ctx.headers.get("x-forwarded-for");
642
+ if (xForwardedFor && trustedProxies.length > 0) {
643
+ const ips = xForwardedFor.split(",").map((ip) => ip.trim());
644
+ for (let i = ips.length - 1; i >= 0; i--) {
645
+ const ip = ips[i];
646
+ if (!trustedProxies.includes(ip)) {
647
+ if (/^[\d.:a-fA-F]+$/.test(ip)) {
648
+ return ip;
649
+ }
650
+ }
651
+ }
652
+ }
653
+ return ctx.server?.requestIP?.(ctx.request)?.address || "unknown";
622
654
  });
623
655
  const skip = options.skip || (() => false);
624
656
  const hits = /* @__PURE__ */ new Map();
@@ -1294,12 +1326,23 @@ function serveStatic(config, prefix) {
1294
1326
  let relative = ctx.path.slice(normalizedPrefix.length);
1295
1327
  if (!relative.startsWith("/") && relative.length > 0) relative = "/" + relative;
1296
1328
  if (relative.length === 0) relative = "/";
1297
- relative = decodeURIComponent(relative);
1298
- const requestPath = join(rootPath, relative);
1299
- if (!requestPath.startsWith(rootPath)) {
1329
+ if (relative.includes("\0")) {
1330
+ return ctx.json({ error: "Forbidden" }, 403);
1331
+ }
1332
+ try {
1333
+ relative = decodeURIComponent(relative);
1334
+ } catch (e) {
1335
+ return ctx.json({ error: "Bad Request" }, 400);
1336
+ }
1337
+ if (relative.includes("\0")) {
1338
+ return ctx.json({ error: "Forbidden" }, 403);
1339
+ }
1340
+ if (relative.includes("../") || relative.includes("..\\")) {
1300
1341
  return ctx.json({ error: "Forbidden" }, 403);
1301
1342
  }
1302
- if (requestPath.includes("\0")) {
1343
+ const requestPath = resolve(join(rootPath, relative));
1344
+ const normalizedRoot = resolve(rootPath);
1345
+ if (!requestPath.startsWith(normalizedRoot + sep) && requestPath !== normalizedRoot) {
1303
1346
  return ctx.json({ error: "Forbidden" }, 403);
1304
1347
  }
1305
1348
  if (config.hooks?.onRequest) {
@@ -2148,10 +2191,13 @@ class ShokupanRouter {
2148
2191
  }
2149
2192
  parsePath(path) {
2150
2193
  const keys = [];
2194
+ if (path.length > 2048) {
2195
+ throw new Error("Path too long");
2196
+ }
2151
2197
  const pattern = path.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
2152
2198
  keys.push(key);
2153
- return "([^/]+)";
2154
- }).replace(/\*\*/g, ".*").replace(/\*/g, "[^/]+");
2199
+ return "([^/]{1,255})";
2200
+ }).replace(/\*\*/g, ".{0,1000}").replace(/\*/g, "[^/]{1,255}");
2155
2201
  return {
2156
2202
  regex: new RegExp(`^${pattern}$`),
2157
2203
  keys
@@ -2939,9 +2985,10 @@ class AuthPlugin extends ShokupanRouter {
2939
2985
  } else {
2940
2986
  return ctx.text("Provider config error", 500);
2941
2987
  }
2942
- ctx.res.headers.set("Set-Cookie", `oauth_state=${state}; Path=/; HttpOnly; Max-Age=600`);
2988
+ const isSecure = ctx.secure;
2989
+ ctx.res.headers.set("Set-Cookie", `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax${isSecure ? "; Secure" : ""}; Max-Age=600`);
2943
2990
  if (codeVerifier) {
2944
- ctx.res.headers.append("Set-Cookie", `oauth_verifier=${codeVerifier}; Path=/; HttpOnly; Max-Age=600`);
2991
+ ctx.res.headers.append("Set-Cookie", `oauth_verifier=${codeVerifier}; Path=/; HttpOnly; SameSite=Lax${isSecure ? "; Secure" : ""}; Max-Age=600`);
2945
2992
  }
2946
2993
  return ctx.redirect(url.toString());
2947
2994
  });
@@ -2982,7 +3029,7 @@ class AuthPlugin extends ShokupanRouter {
2982
3029
  return ctx.json({ token: jwt, user });
2983
3030
  } catch (e) {
2984
3031
  console.error("Auth Error", e);
2985
- return ctx.text("Authentication failed: " + e.message + "\n" + e.stack, 500);
3032
+ return ctx.text("Authentication failed. Please try again.", 500);
2986
3033
  }
2987
3034
  });
2988
3035
  }
@@ -3193,14 +3240,21 @@ function Cors(options = {}) {
3193
3240
  const origin = ctx.headers.get("origin");
3194
3241
  const set = (k, v) => headers.set(k, v);
3195
3242
  const append = (k, v) => headers.append(k, v);
3243
+ if (origin === "null" && opts.origin !== "null") {
3244
+ return next();
3245
+ }
3196
3246
  if (opts.origin === "*") {
3197
3247
  set("Access-Control-Allow-Origin", "*");
3198
3248
  } else if (typeof opts.origin === "string") {
3199
3249
  set("Access-Control-Allow-Origin", opts.origin);
3200
3250
  } else if (Array.isArray(opts.origin)) {
3201
- if (origin && opts.origin.includes(origin)) {
3202
- set("Access-Control-Allow-Origin", origin);
3203
- append("Vary", "Origin");
3251
+ if (origin) {
3252
+ const normalizedOrigin = origin.toLowerCase();
3253
+ const normalizedAllowed = opts.origin.map((o) => o.toLowerCase());
3254
+ if (normalizedAllowed.includes(normalizedOrigin)) {
3255
+ set("Access-Control-Allow-Origin", origin);
3256
+ append("Vary", "Origin");
3257
+ }
3204
3258
  }
3205
3259
  } else if (typeof opts.origin === "function") {
3206
3260
  const allowed = opts.origin(ctx);
@@ -3887,11 +3941,17 @@ function unsign(input, secret) {
3887
3941
  if (typeof secret !== "string") throw new TypeError("Secret string must be provided.");
3888
3942
  const tentValue = input.slice(0, input.lastIndexOf("."));
3889
3943
  const expectedInput = sign(tentValue, secret);
3890
- const expectedBuffer = Buffer.from(expectedInput);
3891
- const inputBuffer = Buffer.from(input);
3892
- if (expectedBuffer.length !== inputBuffer.length) return false;
3893
- const valid = require("crypto").timingSafeEqual(expectedBuffer, inputBuffer);
3894
- return valid ? tentValue : false;
3944
+ const maxLength = Math.max(expectedInput.length, input.length);
3945
+ const paddedExpected = Buffer.alloc(maxLength);
3946
+ const paddedInput = Buffer.alloc(maxLength);
3947
+ Buffer.from(expectedInput).copy(paddedExpected);
3948
+ Buffer.from(input).copy(paddedInput);
3949
+ try {
3950
+ const valid = require("crypto").timingSafeEqual(paddedExpected, paddedInput);
3951
+ return valid ? tentValue : false;
3952
+ } catch {
3953
+ return false;
3954
+ }
3895
3955
  }
3896
3956
  function Session(options) {
3897
3957
  const store = options.store || new MemoryStore();