@usequark/quark-create-app 1.25.14 → 1.25.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.14",
3
+ "version": "1.25.15",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -59,17 +59,43 @@ export async function GET(_request, { params }) {
59
59
  // max-age bounds that window instead.
60
60
  headers.set("Cache-Control", "public, max-age=3600");
61
61
 
62
- // Inline display for images; attachment download for everything else
63
- const isImage = record.mimeType.startsWith("image/");
62
+ // Inline display is limited to raster formats. SVG is deliberately not
63
+ // among them: an SVG is an XML document that can carry <script>, this
64
+ // endpoint is public and same-origin, and CSP `default-src 'self'`
65
+ // permits those scripts to run — so a stored SVG served inline executes
66
+ // in the app's own origin (stored XSS; audit item 8). SVG therefore
67
+ // downloads as an attachment, and its response additionally carries a
68
+ // sandbox CSP so a browser that ignores the disposition still cannot
69
+ // run scripts, fetch, or embed the document.
70
+ const INLINE_SAFE_TYPES = new Set([
71
+ "image/jpeg",
72
+ "image/png",
73
+ "image/gif",
74
+ "image/webp",
75
+ "image/avif",
76
+ ]);
77
+ const isInlineSafe = INLINE_SAFE_TYPES.has(record.mimeType);
78
+ const isSvg = record.mimeType === "image/svg+xml";
64
79
  const safeName = record.originalName.replace(/[\\"\r\n]/g, "_");
65
80
  const encodedName = encodeURIComponent(record.originalName);
66
81
  headers.set(
67
82
  "Content-Disposition",
68
- isImage
83
+ isInlineSafe
69
84
  ? `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`
70
85
  : `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
71
86
  );
72
87
 
88
+ if (isSvg) {
89
+ // `sandbox` with no allow-list: no scripts, no same-origin framing,
90
+ // no form submission. `style-src 'unsafe-inline'` keeps simple
91
+ // presentational SVGs viewable; nothing in this policy can execute
92
+ // code.
93
+ headers.set(
94
+ "Content-Security-Policy",
95
+ "default-src 'none'; style-src 'unsafe-inline'; sandbox",
96
+ );
97
+ }
98
+
73
99
  return new Response(body, { status: 200, headers });
74
100
  } catch (error) {
75
101
  return handleError(error);
@@ -10,11 +10,14 @@
10
10
  */
11
11
 
12
12
  import { getAllowedOrigins } from "@usequark/quark-config/app-url";
13
+ import { createLogger, createRateLimiter } from "@usequark/quark-core/core";
13
14
  import { NextResponse } from "next/server";
14
15
 
15
16
  import { buildContentSecurityPolicy } from "./lib/analytics/umami-csp.js";
16
17
  import { getRateLimitBucket, isRateLimitExempt } from "./lib/proxy-auth";
17
18
 
19
+ const logger = createLogger("proxy");
20
+
18
21
  // ─── 1. Metrics guard ────────────────────────────────────────────────────────
19
22
 
20
23
  function metricsGuard(request) {
@@ -38,8 +41,57 @@ function metricsGuard(request) {
38
41
  return null; // authorised - continue
39
42
  }
40
43
 
41
- // Simple in-memory rate limiter (use Redis for production)
42
- const rateLimit = new Map();
44
+ // Rate limiter: Redis-backed when REDIS_URL is set (multi-instance), otherwise
45
+ // in-memory (single instance). Fails CLOSED by default (see createRateLimiter).
46
+ // The Redis client is created once and reused; ioredis reconnects on its own.
47
+ let rateLimiterPromise = null;
48
+
49
+ // Exported for tests: the Redis-vs-memory backend selection is otherwise only
50
+ // observable through a live Redis connection, which the unit suite doesn't have.
51
+ // The reset clears the memoised instance so a test can flip REDIS_URL and
52
+ // re-select a backend; production code never calls it.
53
+ export function __resetRateLimiterForTests() {
54
+ rateLimiterPromise = null;
55
+ }
56
+
57
+ export async function getRateLimiter() {
58
+ if (rateLimiterPromise) return rateLimiterPromise;
59
+
60
+ rateLimiterPromise = (async () => {
61
+ if (!process.env.REDIS_URL) {
62
+ return createRateLimiter({ type: "memory" });
63
+ }
64
+
65
+ try {
66
+ const { default: Redis } = await import("ioredis");
67
+ const redisClient = new Redis(process.env.REDIS_URL, {
68
+ maxRetriesPerRequest: 1,
69
+ enableReadyCheck: true,
70
+ retryStrategy(times) {
71
+ return Math.min(times * 50, 2000);
72
+ },
73
+ });
74
+ // ioredis emits an 'error' event on connection trouble; without a
75
+ // listener Node treats it as unhandled and kills the process.
76
+ redisClient.on("error", (err) => {
77
+ logger.error("Redis rate-limiter client error", {
78
+ message: err?.message ?? String(err),
79
+ });
80
+ });
81
+ return createRateLimiter({ type: "redis", redisClient });
82
+ } catch (error) {
83
+ logger.error("Redis rate limiter unavailable; using in-memory", {
84
+ message: error?.message ?? String(error),
85
+ });
86
+ // Losing Redis must not lose rate limiting entirely: a per-instance
87
+ // in-memory limiter is still a limiter. It just no longer spans
88
+ // instances, so a distributed attacker gets one budget per instance.
89
+ return createRateLimiter({ type: "memory" });
90
+ }
91
+ })();
92
+
93
+ return rateLimiterPromise;
94
+ }
43
95
 
44
96
  /**
45
97
  * Resolve the client IP for rate-limit keying.
@@ -83,61 +135,25 @@ const RATE_LIMIT_CONFIG = {
83
135
  };
84
136
 
85
137
  /**
86
- * Rate limiting implementation
138
+ * Rate limiting implementation.
139
+ *
140
+ * Delegates to the core limiter (`createRateLimiter`): Redis-backed when
141
+ * REDIS_URL is set, in-memory otherwise. Its `checkLimit(key, max, window)`
142
+ * returns `{ limited, remaining, resetTime }` — the same shape this proxy
143
+ * already returned, so the response headers below are unchanged.
87
144
  */
88
- function checkRateLimit(ip, path, method) {
89
- const now = Date.now();
90
- const key = `${ip}:${path}`;
91
-
145
+ async function checkRateLimit(ip, path, method) {
146
+ const limiter = await getRateLimiter();
92
147
  const maxRequests =
93
148
  RATE_LIMIT_CONFIG.maxRequests[getRateLimitBucket(path, method)];
94
149
 
95
- // Get or create rate limit record
96
- const record = rateLimit.get(key) || {
97
- count: 0,
98
- resetTime: now + RATE_LIMIT_CONFIG.windowMs,
99
- };
100
-
101
- // Reset if window has passed
102
- if (now > record.resetTime) {
103
- record.count = 0;
104
- record.resetTime = now + RATE_LIMIT_CONFIG.windowMs;
105
- }
106
-
107
- // Check if limit exceeded
108
- if (record.count >= maxRequests) {
109
- return {
110
- limited: true,
111
- resetTime: record.resetTime,
112
- remaining: 0,
113
- };
114
- }
115
-
116
- // Increment counter
117
- record.count++;
118
- rateLimit.set(key, record);
119
-
120
- return {
121
- limited: false,
122
- resetTime: record.resetTime,
123
- remaining: maxRequests - record.count,
124
- };
150
+ return limiter.checkLimit(
151
+ `${ip}:${path}`,
152
+ maxRequests,
153
+ RATE_LIMIT_CONFIG.windowMs,
154
+ );
125
155
  }
126
156
 
127
- /**
128
- * Clean up old rate limit records periodically.
129
- * `unref()` so the timer never holds the Node process open on shutdown.
130
- */
131
- const rateLimitCleanup = setInterval(() => {
132
- const now = Date.now();
133
- for (const [key, record] of rateLimit.entries()) {
134
- if (now > record.resetTime) {
135
- rateLimit.delete(key);
136
- }
137
- }
138
- }, 60 * 1000); // Clean up every minute
139
- if (typeof rateLimitCleanup.unref === "function") rateLimitCleanup.unref();
140
-
141
157
  /**
142
158
  * CORS configuration
143
159
  */
@@ -225,7 +241,7 @@ export async function proxy(request) {
225
241
  const ip = getClientIp(request);
226
242
  const rateLimitBucket = getRateLimitBucket(pathname, request.method);
227
243
  const maxRequests = RATE_LIMIT_CONFIG.maxRequests[rateLimitBucket];
228
- const rateLimitResult = checkRateLimit(ip, pathname, request.method);
244
+ const rateLimitResult = await checkRateLimit(ip, pathname, request.method);
229
245
 
230
246
  if (rateLimitResult.limited) {
231
247
  const retryAfter = Math.ceil(