@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
|
@@ -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
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|