@usequark/quark-create-app 1.25.13 → 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 +1 -1
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +29 -3
- package/templates/base-project/apps/web/src/lib/auth.js +81 -2
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +0 -3
- package/templates/base-project/apps/web/src/proxy.js +68 -52
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +1 -12
- package/templates/base-project/packages/db/prisma/schema.prisma +10 -4
- package/templates/base-project/packages/db/src/queries.js +1 -0
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);
|
|
@@ -2,7 +2,7 @@ import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
|
2
2
|
import { createAuthConfig, verifyPassword } from "@usequark/quark-core/auth";
|
|
3
3
|
import { createLogger } from "@usequark/quark-core/core";
|
|
4
4
|
import { AppError } from "@usequark/quark-core/errors";
|
|
5
|
-
import { Prisma, prisma, user } from "@usequark/quark-db";
|
|
5
|
+
import { Prisma, prisma, user as userQueries } from "@usequark/quark-db";
|
|
6
6
|
import NextAuth from "next-auth";
|
|
7
7
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
8
8
|
import GithubProvider from "next-auth/providers/github";
|
|
@@ -66,7 +66,7 @@ const providers = [
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
|
-
const existingUser = await
|
|
69
|
+
const existingUser = await userQueries.findByEmail(credentials.email);
|
|
70
70
|
|
|
71
71
|
if (!existingUser?.password) {
|
|
72
72
|
return null;
|
|
@@ -156,6 +156,64 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Re-validate the session against the database on every request.
|
|
161
|
+
*
|
|
162
|
+
* Web sessions are signed JWTs: a credentials provider forces the JWT
|
|
163
|
+
* strategy (Auth.js rejects `strategy: "database"` when every provider is
|
|
164
|
+
* credentials, and scaffolds always ship one), and the cookie would otherwise
|
|
165
|
+
* stay valid for its full 30 days no matter what happened to the account
|
|
166
|
+
* behind it. That was the review's "logins can't be revoked" finding — banning
|
|
167
|
+
* a user, demoting an admin, or changing a password did nothing until the
|
|
168
|
+
* token expired on its own.
|
|
169
|
+
*
|
|
170
|
+
* The revocation check is a single indexed primary-key read, which is the
|
|
171
|
+
* same cost Auth.js itself pays for database sessions — but it works for
|
|
172
|
+
* credentials apps too:
|
|
173
|
+
*
|
|
174
|
+
* - user gone (deleted / banned by deletion) → reject, cookie cleared
|
|
175
|
+
* - `passwordChangedAt` after the token's iat → reject, cookie cleared
|
|
176
|
+
* - role or profile changed → refreshed into the token,
|
|
177
|
+
* so `requireRole` reads the current role, not the sign-in-time one
|
|
178
|
+
*
|
|
179
|
+
* Returning null from the `jwt` callback is Auth.js's documented "invalidate
|
|
180
|
+
* this session" signal: it clears the cookie and the request continues
|
|
181
|
+
* unauthenticated.
|
|
182
|
+
*/
|
|
183
|
+
async function validateSessionToken(token) {
|
|
184
|
+
if (typeof token.id !== "string" || token.id.length === 0) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const account = await userQueries.findById(token.id);
|
|
190
|
+
|
|
191
|
+
if (!account) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
account.passwordChangedAt &&
|
|
197
|
+
typeof token.iat === "number" &&
|
|
198
|
+
account.passwordChangedAt.getTime() / 1000 > token.iat
|
|
199
|
+
) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
token.email = account.email;
|
|
204
|
+
token.name = account.name;
|
|
205
|
+
token.role = account.role;
|
|
206
|
+
return token;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Fail closed: an unreachable database invalidates the session rather
|
|
209
|
+
// than letting an unverified cookie through. The next request retries.
|
|
210
|
+
logger.error("Session validation failed", {
|
|
211
|
+
message: err?.message ?? String(err),
|
|
212
|
+
});
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
159
217
|
export function getAuthOptions() {
|
|
160
218
|
return createAuthConfig({
|
|
161
219
|
adapter: PrismaAdapter(prisma),
|
|
@@ -172,7 +230,28 @@ export function getAuthOptions() {
|
|
|
172
230
|
},
|
|
173
231
|
},
|
|
174
232
|
session: {
|
|
233
|
+
// JWT is not a preference here, it is a constraint: Auth.js throws
|
|
234
|
+
// `UnsupportedStrategy` when strategy is "database" and every
|
|
235
|
+
// configured provider is credentials, and the scaffold always ships
|
|
236
|
+
// the credentials provider. Revocation is therefore enforced by
|
|
237
|
+
// `validateSessionToken` above, not by server-side session rows.
|
|
175
238
|
strategy: "jwt",
|
|
239
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
240
|
+
},
|
|
241
|
+
callbacks: {
|
|
242
|
+
async jwt({ token, user: signedInUser }) {
|
|
243
|
+
// Sign-in: stamp the identity once; the DB check below is for
|
|
244
|
+
// every subsequent request.
|
|
245
|
+
if (signedInUser) {
|
|
246
|
+
token.id = signedInUser.id;
|
|
247
|
+
token.email = signedInUser.email;
|
|
248
|
+
token.name = signedInUser.name;
|
|
249
|
+
token.role = signedInUser.role || "viewer";
|
|
250
|
+
return token;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return validateSessionToken(token);
|
|
254
|
+
},
|
|
176
255
|
},
|
|
177
256
|
});
|
|
178
257
|
}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { getAuthSecret } from "@usequark/quark-core/auth";
|
|
2
|
-
import { getToken } from "next-auth/jwt";
|
|
3
|
-
|
|
4
1
|
const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
|
|
5
2
|
// `callback/credentials` is where the next-auth client POSTs a credentials
|
|
6
3
|
// sign-in (next-auth/react posts to `callback/<provider>` for credentials
|
|
@@ -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(
|
package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql
CHANGED
|
@@ -16,6 +16,7 @@ CREATE TABLE "User" (
|
|
|
16
16
|
"password" TEXT,
|
|
17
17
|
"image" TEXT,
|
|
18
18
|
"role" "UserRole" NOT NULL DEFAULT 'viewer',
|
|
19
|
+
"passwordChangedAt" TIMESTAMP(3),
|
|
19
20
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
20
21
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
21
22
|
|
|
@@ -117,9 +118,6 @@ CREATE TABLE "AuditLog" (
|
|
|
117
118
|
-- CreateIndex
|
|
118
119
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
119
120
|
|
|
120
|
-
-- CreateIndex
|
|
121
|
-
CREATE INDEX "User_email_idx" ON "User"("email");
|
|
122
|
-
|
|
123
121
|
-- CreateIndex
|
|
124
122
|
CREATE INDEX "User_createdAt_idx" ON "User"("createdAt");
|
|
125
123
|
|
|
@@ -141,9 +139,6 @@ CREATE INDEX "Session_expires_idx" ON "Session"("expires");
|
|
|
141
139
|
-- CreateIndex
|
|
142
140
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
|
143
141
|
|
|
144
|
-
-- CreateIndex
|
|
145
|
-
CREATE INDEX "VerificationToken_token_idx" ON "VerificationToken"("token");
|
|
146
|
-
|
|
147
142
|
-- CreateIndex
|
|
148
143
|
CREATE INDEX "VerificationToken_expires_idx" ON "VerificationToken"("expires");
|
|
149
144
|
|
|
@@ -153,12 +148,6 @@ CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationTok
|
|
|
153
148
|
-- CreateIndex
|
|
154
149
|
CREATE INDEX "Job_queue_idx" ON "Job"("queue");
|
|
155
150
|
|
|
156
|
-
-- CreateIndex
|
|
157
|
-
CREATE INDEX "Job_status_idx" ON "Job"("status");
|
|
158
|
-
|
|
159
|
-
-- CreateIndex
|
|
160
|
-
CREATE INDEX "Job_runAt_idx" ON "Job"("runAt");
|
|
161
|
-
|
|
162
151
|
-- CreateIndex
|
|
163
152
|
CREATE INDEX "Job_status_runAt_idx" ON "Job"("status", "runAt");
|
|
164
153
|
|
|
@@ -29,6 +29,10 @@ model User {
|
|
|
29
29
|
password String?
|
|
30
30
|
image String?
|
|
31
31
|
role UserRole @default(viewer)
|
|
32
|
+
// Bumped whenever the account's password changes. Session tokens (web JWTs
|
|
33
|
+
// and mobile refresh tokens) issued before this instant are rejected, so a
|
|
34
|
+
// stolen or pre-change session dies with the password it was born under.
|
|
35
|
+
passwordChangedAt DateTime?
|
|
32
36
|
createdAt DateTime @default(now())
|
|
33
37
|
updatedAt DateTime @updatedAt
|
|
34
38
|
|
|
@@ -37,7 +41,8 @@ model User {
|
|
|
37
41
|
auditLogs AuditLog[]
|
|
38
42
|
files File[]
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
// `email` is already @unique (User_email_key); a second plain index on the
|
|
45
|
+
// same column only adds write cost.
|
|
41
46
|
@@index([createdAt])
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -85,8 +90,9 @@ model VerificationToken {
|
|
|
85
90
|
createdAt DateTime @default(now())
|
|
86
91
|
updatedAt DateTime @updatedAt
|
|
87
92
|
|
|
93
|
+
// `token` is already @unique (VerificationToken_token_key); a second plain
|
|
94
|
+
// index on the same column only adds write cost.
|
|
88
95
|
@@unique([identifier, token])
|
|
89
|
-
@@index([token])
|
|
90
96
|
@@index([expires])
|
|
91
97
|
}
|
|
92
98
|
|
|
@@ -106,9 +112,9 @@ model Job {
|
|
|
106
112
|
createdAt DateTime @default(now())
|
|
107
113
|
updatedAt DateTime @updatedAt
|
|
108
114
|
|
|
115
|
+
// (status, runAt) serves status-only queries through its leftmost prefix;
|
|
116
|
+
// single-column [status] and [runAt] indexes were removed as redundant.
|
|
109
117
|
@@index([queue])
|
|
110
|
-
@@index([status])
|
|
111
|
-
@@index([runAt])
|
|
112
118
|
@@index([status, runAt])
|
|
113
119
|
@@index([createdAt])
|
|
114
120
|
}
|