@vex-chat/spire 1.0.0 → 1.0.2
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/README.md +38 -38
- package/dist/ClientManager.d.ts +1 -3
- package/dist/ClientManager.js +7 -50
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +49 -6
- package/dist/Database.js +255 -80
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +0 -3
- package/dist/Spire.js +116 -81
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +1 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +3 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +12 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -0
- package/dist/run.js +0 -1
- package/dist/run.js.map +1 -1
- package/dist/server/avatar.d.ts +1 -3
- package/dist/server/avatar.js +7 -11
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.d.ts +3 -6
- package/dist/server/errors.js +2 -28
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.d.ts +1 -2
- package/dist/server/file.js +5 -7
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +1 -2
- package/dist/server/index.js +35 -33
- package/dist/server/index.js.map +1 -1
- package/dist/server/invite.d.ts +1 -2
- package/dist/server/invite.js +1 -1
- package/dist/server/invite.js.map +1 -1
- package/dist/server/rateLimit.d.ts +13 -3
- package/dist/server/rateLimit.js +57 -6
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -2
- package/dist/server/user.js +10 -8
- package/dist/server/user.js.map +1 -1
- package/dist/utils/jwtSecret.d.ts +3 -3
- package/dist/utils/jwtSecret.js +5 -6
- package/dist/utils/jwtSecret.js.map +1 -1
- package/dist/utils/loadEnv.js +1 -1
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +17 -8
- package/src/ClientManager.ts +7 -60
- package/src/Database.ts +308 -89
- package/src/Spire.ts +122 -119
- package/src/__tests__/Database.spec.ts +0 -17
- package/src/db/schema.ts +1 -0
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +18 -0
- package/src/run.ts +0 -1
- package/src/server/avatar.ts +7 -14
- package/src/server/errors.ts +3 -32
- package/src/server/file.ts +5 -10
- package/src/server/index.ts +37 -37
- package/src/server/invite.ts +0 -2
- package/src/server/rateLimit.ts +43 -9
- package/src/server/user.ts +9 -9
- package/src/utils/jwtSecret.ts +5 -6
- package/src/utils/loadEnv.ts +1 -1
- package/dist/__tests__/Database.spec.d.ts +0 -1
- package/dist/__tests__/Database.spec.js +0 -136
- package/dist/__tests__/Database.spec.js.map +0 -1
- package/dist/utils/createLogger.d.ts +0 -5
- package/dist/utils/createLogger.js +0 -35
- package/dist/utils/createLogger.js.map +0 -1
- package/src/utils/createLogger.ts +0 -47
package/src/server/errors.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
* (which include database internals, file paths, and stack traces)
|
|
10
10
|
* back to the client. The new pattern: throw `AppError(status, msg)`
|
|
11
11
|
* or let an unknown error propagate, and the single 4-arg middleware
|
|
12
|
-
* below converts it into a JSON response with a generic message
|
|
13
|
-
* while logging the full internals server-side via winston.
|
|
12
|
+
* below converts it into a JSON response with a generic message.
|
|
14
13
|
*
|
|
15
14
|
* - **CWE-79 / CWE-116 — Exception text reinterpreted as HTML.** Express's
|
|
16
15
|
* default finalhandler sends thrown `Error` objects as
|
|
@@ -27,7 +26,6 @@
|
|
|
27
26
|
* `express-async-errors` shim needed.
|
|
28
27
|
*/
|
|
29
28
|
import type { ErrorRequestHandler } from "express";
|
|
30
|
-
import type winston from "winston";
|
|
31
29
|
|
|
32
30
|
import { randomUUID } from "node:crypto";
|
|
33
31
|
|
|
@@ -39,8 +37,7 @@ import { ZodError } from "zod/v4";
|
|
|
39
37
|
* - `status` — HTTP status code (400, 401, 403, 404, 409, etc.)
|
|
40
38
|
* - `message` — client-safe message, MUST NOT contain request data
|
|
41
39
|
* (route params, body fields, query strings). Anything operator-
|
|
42
|
-
* only (database errors, file paths)
|
|
43
|
-
* in this string.
|
|
40
|
+
* only (database errors, file paths) should not appear in this string.
|
|
44
41
|
*
|
|
45
42
|
* Anything that isn't an `AppError` (raw `Error`, `TypeError`, a
|
|
46
43
|
* rejected promise from a DB query, etc.) is treated by the central
|
|
@@ -68,8 +65,7 @@ export class AppError extends Error {
|
|
|
68
65
|
* api.use(errorHandler(log));
|
|
69
66
|
*/
|
|
70
67
|
export const errorHandler =
|
|
71
|
-
(
|
|
72
|
-
(err, req, res, _next) => {
|
|
68
|
+
(): ErrorRequestHandler => (err, _req, res, _next) => {
|
|
73
69
|
// If headers already went out there's nothing safe to do except
|
|
74
70
|
// let Express's default handler close the socket.
|
|
75
71
|
if (res.headersSent) {
|
|
@@ -95,31 +91,6 @@ export const errorHandler =
|
|
|
95
91
|
clientMessage = err.message;
|
|
96
92
|
}
|
|
97
93
|
|
|
98
|
-
// Log the full internals server-side. `err instanceof Error` also
|
|
99
|
-
// catches AppError (extends Error), so we get stacks and messages
|
|
100
|
-
// for every branch in the logs.
|
|
101
|
-
if (err instanceof Error) {
|
|
102
|
-
log.error("request failed", {
|
|
103
|
-
error: {
|
|
104
|
-
message: err.message,
|
|
105
|
-
name: err.name,
|
|
106
|
-
stack: err.stack,
|
|
107
|
-
},
|
|
108
|
-
method: req.method,
|
|
109
|
-
path: req.path,
|
|
110
|
-
requestId,
|
|
111
|
-
status,
|
|
112
|
-
});
|
|
113
|
-
} else {
|
|
114
|
-
log.error("request failed", {
|
|
115
|
-
error: String(err),
|
|
116
|
-
method: req.method,
|
|
117
|
-
path: req.path,
|
|
118
|
-
requestId,
|
|
119
|
-
status,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
94
|
// ALWAYS JSON — prevents the exception-text-as-HTML XSS vector.
|
|
124
95
|
res.status(status)
|
|
125
96
|
.type("application/json")
|
package/src/server/file.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Database } from "../Database.ts";
|
|
2
2
|
import type { FileSQL } from "@vex-chat/types";
|
|
3
|
-
import type winston from "winston";
|
|
4
3
|
|
|
5
4
|
import * as fs from "node:fs";
|
|
6
5
|
import * as fsp from "node:fs/promises";
|
|
@@ -23,7 +22,7 @@ import { protect } from "./index.ts";
|
|
|
23
22
|
|
|
24
23
|
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
25
24
|
|
|
26
|
-
export const getFileRouter = (db: Database
|
|
25
|
+
export const getFileRouter = (db: Database) => {
|
|
27
26
|
const router = express.Router();
|
|
28
27
|
|
|
29
28
|
router.get("/:id", protect, async (req, res) => {
|
|
@@ -37,8 +36,8 @@ export const getFileRouter = (db: Database, log: winston.Logger) => {
|
|
|
37
36
|
res.sendStatus(404);
|
|
38
37
|
} else {
|
|
39
38
|
const stream = fs.createReadStream("./files/" + entry.fileID);
|
|
40
|
-
stream.on("error", (
|
|
41
|
-
|
|
39
|
+
stream.on("error", (_err) => {
|
|
40
|
+
// debugger: file stream read error
|
|
42
41
|
res.sendStatus(500);
|
|
43
42
|
});
|
|
44
43
|
stream.pipe(res);
|
|
@@ -105,12 +104,10 @@ export const getFileRouter = (db: Database, log: winston.Logger) => {
|
|
|
105
104
|
const newFile: FileSQL = {
|
|
106
105
|
fileID: crypto.randomUUID(),
|
|
107
106
|
nonce: payload.nonce,
|
|
108
|
-
owner:
|
|
107
|
+
owner: deviceDetails.deviceID,
|
|
109
108
|
};
|
|
110
109
|
|
|
111
110
|
await fsp.writeFile("files/" + newFile.fileID, buf);
|
|
112
|
-
log.info("Wrote new file " + newFile.fileID);
|
|
113
|
-
|
|
114
111
|
await db.createFile(newFile);
|
|
115
112
|
res.send(msgpack.encode(newFile));
|
|
116
113
|
});
|
|
@@ -157,12 +154,10 @@ export const getFileRouter = (db: Database, log: winston.Logger) => {
|
|
|
157
154
|
const newFile: FileSQL = {
|
|
158
155
|
fileID: crypto.randomUUID(),
|
|
159
156
|
nonce: payload.nonce,
|
|
160
|
-
owner:
|
|
157
|
+
owner: deviceDetails.deviceID,
|
|
161
158
|
};
|
|
162
159
|
|
|
163
160
|
await fsp.writeFile("files/" + newFile.fileID, req.file.buffer);
|
|
164
|
-
log.info("Wrote new file " + newFile.fileID);
|
|
165
|
-
|
|
166
161
|
await db.createFile(newFile);
|
|
167
162
|
res.send(msgpack.encode(newFile));
|
|
168
163
|
},
|
package/src/server/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Database } from "../Database.ts";
|
|
2
2
|
import type { Emoji } from "@vex-chat/types";
|
|
3
|
-
import type winston from "winston";
|
|
4
3
|
|
|
5
4
|
import * as fs from "node:fs";
|
|
6
5
|
import * as fsp from "node:fs/promises";
|
|
@@ -15,7 +14,6 @@ import cors from "cors";
|
|
|
15
14
|
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
|
|
16
15
|
import helmet from "helmet";
|
|
17
16
|
import jwt from "jsonwebtoken";
|
|
18
|
-
import morgan from "morgan";
|
|
19
17
|
import multer from "multer";
|
|
20
18
|
import parseDuration from "parse-duration";
|
|
21
19
|
import { stringify as uuidStringify } from "uuid";
|
|
@@ -156,8 +154,6 @@ export const msgpackParser: express.RequestHandler = (req, res, next) => {
|
|
|
156
154
|
next();
|
|
157
155
|
};
|
|
158
156
|
|
|
159
|
-
const isProduction = process.env["NODE_ENV"] === "production";
|
|
160
|
-
|
|
161
157
|
const directories = ["files", "avatars"];
|
|
162
158
|
for (const dir of directories) {
|
|
163
159
|
if (!fs.existsSync(dir)) {
|
|
@@ -168,7 +164,6 @@ for (const dir of directories) {
|
|
|
168
164
|
export const initApp = (
|
|
169
165
|
api: express.Application,
|
|
170
166
|
db: Database,
|
|
171
|
-
log: winston.Logger,
|
|
172
167
|
tokenValidator: (key: string, scope: TokenScopes) => boolean,
|
|
173
168
|
signKeys: KeyPair,
|
|
174
169
|
notify: (
|
|
@@ -180,10 +175,10 @@ export const initApp = (
|
|
|
180
175
|
) => void,
|
|
181
176
|
) => {
|
|
182
177
|
// INIT ROUTERS
|
|
183
|
-
const userRouter = getUserRouter(db,
|
|
184
|
-
const fileRouter = getFileRouter(db
|
|
185
|
-
const avatarRouter = getAvatarRouter(
|
|
186
|
-
const inviteRouter = getInviteRouter(db,
|
|
178
|
+
const userRouter = getUserRouter(db, tokenValidator);
|
|
179
|
+
const fileRouter = getFileRouter(db);
|
|
180
|
+
const avatarRouter = getAvatarRouter();
|
|
181
|
+
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
187
182
|
|
|
188
183
|
// MIDDLEWARE
|
|
189
184
|
// Global per-IP rate limit is the FIRST middleware so a flooded
|
|
@@ -197,18 +192,32 @@ export const initApp = (
|
|
|
197
192
|
type: "application/msgpack",
|
|
198
193
|
}),
|
|
199
194
|
);
|
|
200
|
-
|
|
201
|
-
api.use(helmet());
|
|
202
|
-
}
|
|
195
|
+
api.use(helmet());
|
|
203
196
|
api.use(msgpackParser);
|
|
204
197
|
api.use(checkAuth);
|
|
205
198
|
api.use(checkDevice);
|
|
206
199
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
200
|
+
// Browser clients (web, Tauri, Capacitor, etc.) hit Spire from arbitrary
|
|
201
|
+
// origins when self-hosted or embedded in third-party apps. libvex uses
|
|
202
|
+
// `Authorization: Bearer` only (no cookies), so reflecting `Origin` is the
|
|
203
|
+
// usual bearer-API pattern. Set `CORS_ORIGINS` to a comma-separated allowlist
|
|
204
|
+
// when an operator wants to restrict which frontends may call the API.
|
|
205
|
+
const corsRaw = process.env["CORS_ORIGINS"];
|
|
206
|
+
const corsOrigins = corsRaw
|
|
207
|
+
? corsRaw
|
|
208
|
+
.split(",")
|
|
209
|
+
.map((o) => o.trim())
|
|
210
|
+
.filter((o) => o.length > 0)
|
|
211
|
+
: [];
|
|
212
|
+
api.use(
|
|
213
|
+
cors({
|
|
214
|
+
credentials: true,
|
|
215
|
+
origin:
|
|
216
|
+
corsOrigins.length > 0
|
|
217
|
+
? corsOrigins
|
|
218
|
+
: true /* reflect request Origin */,
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
212
221
|
|
|
213
222
|
api.get("/server/:id", protect, async (req, res) => {
|
|
214
223
|
const server = await db.retrieveServer(getParam(req, "id"));
|
|
@@ -259,7 +268,6 @@ export const initApp = (
|
|
|
259
268
|
POWER_LEVELS.INVITE,
|
|
260
269
|
)
|
|
261
270
|
) {
|
|
262
|
-
log.warn("No permission!");
|
|
263
271
|
res.sendStatus(401);
|
|
264
272
|
return;
|
|
265
273
|
}
|
|
@@ -649,8 +657,8 @@ export const initApp = (
|
|
|
649
657
|
res.set("Cache-control", "public, max-age=31536000");
|
|
650
658
|
|
|
651
659
|
const stream = fs.createReadStream(filePath);
|
|
652
|
-
stream.on("error", (
|
|
653
|
-
|
|
660
|
+
stream.on("error", (_err) => {
|
|
661
|
+
// debugger: emoji stream read error
|
|
654
662
|
res.sendStatus(500);
|
|
655
663
|
});
|
|
656
664
|
stream.pipe(res);
|
|
@@ -703,10 +711,11 @@ export const initApp = (
|
|
|
703
711
|
}
|
|
704
712
|
if (!payload.name) {
|
|
705
713
|
res.sendStatus(400);
|
|
714
|
+
return;
|
|
706
715
|
}
|
|
707
716
|
if (Buffer.byteLength(buf) > 256000) {
|
|
708
|
-
log.warn("File too big.");
|
|
709
717
|
res.sendStatus(413);
|
|
718
|
+
return;
|
|
710
719
|
}
|
|
711
720
|
|
|
712
721
|
const mimeType = await fileTypeFromBuffer(buf);
|
|
@@ -730,10 +739,9 @@ export const initApp = (
|
|
|
730
739
|
try {
|
|
731
740
|
// write the file to disk
|
|
732
741
|
await fsp.writeFile("emoji/" + emoji.emojiID, buf);
|
|
733
|
-
log.info("Wrote new emoji " + emoji.emojiID);
|
|
734
742
|
res.send(msgpack.encode(emoji));
|
|
735
|
-
} catch (
|
|
736
|
-
|
|
743
|
+
} catch (_err: unknown) {
|
|
744
|
+
// debugger: emoji write failed
|
|
737
745
|
res.sendStatus(500);
|
|
738
746
|
}
|
|
739
747
|
});
|
|
@@ -786,17 +794,17 @@ export const initApp = (
|
|
|
786
794
|
|
|
787
795
|
if (!payload.name) {
|
|
788
796
|
res.sendStatus(400);
|
|
797
|
+
return;
|
|
789
798
|
}
|
|
790
799
|
|
|
791
800
|
if (!req.file) {
|
|
792
|
-
log.warn("MISSING FILE");
|
|
793
801
|
res.sendStatus(400);
|
|
794
802
|
return;
|
|
795
803
|
}
|
|
796
804
|
|
|
797
805
|
if (Buffer.byteLength(req.file.buffer) > 256000) {
|
|
798
|
-
log.warn("File too big.");
|
|
799
806
|
res.sendStatus(413);
|
|
807
|
+
return;
|
|
800
808
|
}
|
|
801
809
|
|
|
802
810
|
const mimeType = await fileTypeFromBuffer(req.file.buffer);
|
|
@@ -820,10 +828,9 @@ export const initApp = (
|
|
|
820
828
|
try {
|
|
821
829
|
// write the file to disk
|
|
822
830
|
await fsp.writeFile("emoji/" + emoji.emojiID, req.file.buffer);
|
|
823
|
-
log.info("Wrote new emoji " + emoji.emojiID);
|
|
824
831
|
res.send(msgpack.encode(emoji));
|
|
825
|
-
} catch (
|
|
826
|
-
|
|
832
|
+
} catch (_err: unknown) {
|
|
833
|
+
// debugger: emoji write failed
|
|
827
834
|
res.sendStatus(500);
|
|
828
835
|
}
|
|
829
836
|
},
|
|
@@ -844,12 +851,5 @@ export const initApp = (
|
|
|
844
851
|
// (client-safe status + message) and programmer errors (generic 500
|
|
845
852
|
// with full details logged server-side). See src/server/errors.ts
|
|
846
853
|
// for the CWE mapping.
|
|
847
|
-
api.use(errorHandler(
|
|
848
|
-
};
|
|
849
|
-
|
|
850
|
-
/**
|
|
851
|
-
* @ignore
|
|
852
|
-
*/
|
|
853
|
-
const jestRun = () => {
|
|
854
|
-
return process.env["JEST_WORKER_ID"] !== undefined;
|
|
854
|
+
api.use(errorHandler());
|
|
855
855
|
};
|
package/src/server/invite.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Database } from "../Database.ts";
|
|
2
2
|
import type { TokenScopes } from "@vex-chat/types";
|
|
3
|
-
import type winston from "winston";
|
|
4
3
|
|
|
5
4
|
import express from "express";
|
|
6
5
|
|
|
@@ -12,7 +11,6 @@ import { protect } from "./index.ts";
|
|
|
12
11
|
|
|
13
12
|
export const getInviteRouter = (
|
|
14
13
|
db: Database,
|
|
15
|
-
log: winston.Logger,
|
|
16
14
|
tokenValidator: (key: string, scope: TokenScopes) => boolean,
|
|
17
15
|
notify: (
|
|
18
16
|
userID: string,
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
import type { Request } from "express";
|
|
2
|
+
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
|
6
|
+
|
|
7
|
+
/** HTTP header carrying the dev API key (must match {@link process.env.DEV_API_KEY}). */
|
|
8
|
+
export const DEV_API_KEY_HEADER = "x-dev-api-key";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* When `DEV_API_KEY` is set in the environment, any request whose
|
|
12
|
+
* `x-dev-api-key` header matches (constant-time) skips all in-process rate
|
|
13
|
+
* limiters. Dev / load-testing escape hatch only — never set in production.
|
|
14
|
+
* (Future: first-class API keys with scopes may reuse this header name.)
|
|
15
|
+
*/
|
|
16
|
+
export function devApiKeySkipsRateLimits(req: Request): boolean {
|
|
17
|
+
const configured = process.env["DEV_API_KEY"]?.trim() ?? "";
|
|
18
|
+
if (configured.length === 0) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const presented = req.get(DEV_API_KEY_HEADER);
|
|
22
|
+
if (!presented || presented.length !== configured.length) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return timingSafeEqual(
|
|
27
|
+
Buffer.from(presented, "utf8"),
|
|
28
|
+
Buffer.from(configured, "utf8"),
|
|
29
|
+
);
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
1
35
|
/**
|
|
2
36
|
* Rate limiting middleware.
|
|
3
37
|
*
|
|
@@ -21,9 +55,6 @@
|
|
|
21
55
|
* `trust proxy` must be set on the Express app (see Spire.ts) so
|
|
22
56
|
* `req.ip` returns the real client address, not the immediate proxy.
|
|
23
57
|
*/
|
|
24
|
-
import type { Request } from "express";
|
|
25
|
-
|
|
26
|
-
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
|
27
58
|
|
|
28
59
|
/**
|
|
29
60
|
* Bucket requests by the real client IP, IPv6-safe.
|
|
@@ -38,14 +69,15 @@ const keyByIp = (req: Request): string => ipKeyGenerator(req.ip ?? "");
|
|
|
38
69
|
/**
|
|
39
70
|
* Global per-IP limiter. Applied app-wide via `api.use(globalLimiter)`.
|
|
40
71
|
*
|
|
41
|
-
*
|
|
72
|
+
* 3000 requests per 15 minutes per client IP. A human chatting via a
|
|
42
73
|
* browser or the libvex client won't come close; a single-host DoS
|
|
43
74
|
* gets throttled quickly.
|
|
44
75
|
*/
|
|
45
76
|
export const globalLimiter = rateLimit({
|
|
46
77
|
keyGenerator: keyByIp,
|
|
47
78
|
legacyHeaders: false,
|
|
48
|
-
limit:
|
|
79
|
+
limit: 3000,
|
|
80
|
+
skip: devApiKeySkipsRateLimits,
|
|
49
81
|
standardHeaders: "draft-7",
|
|
50
82
|
windowMs: 15 * 60 * 1000,
|
|
51
83
|
});
|
|
@@ -54,7 +86,7 @@ export const globalLimiter = rateLimit({
|
|
|
54
86
|
* Strict auth endpoint limiter. Applied per-route to /auth, /register,
|
|
55
87
|
* and /auth/device.
|
|
56
88
|
*
|
|
57
|
-
*
|
|
89
|
+
* 50 failed attempts per 15 minutes per IP. Successful logins don't
|
|
58
90
|
* count (`skipSuccessfulRequests`), so a normal user doesn't lock
|
|
59
91
|
* themselves out by fat-fingering a password once. Blocks brute force
|
|
60
92
|
* (CWE-307) without harming UX.
|
|
@@ -62,7 +94,8 @@ export const globalLimiter = rateLimit({
|
|
|
62
94
|
export const authLimiter = rateLimit({
|
|
63
95
|
keyGenerator: keyByIp,
|
|
64
96
|
legacyHeaders: false,
|
|
65
|
-
limit:
|
|
97
|
+
limit: 50,
|
|
98
|
+
skip: devApiKeySkipsRateLimits,
|
|
66
99
|
skipSuccessfulRequests: true,
|
|
67
100
|
standardHeaders: "draft-7",
|
|
68
101
|
windowMs: 15 * 60 * 1000,
|
|
@@ -74,13 +107,14 @@ export const authLimiter = rateLimit({
|
|
|
74
107
|
* upload attempts per minute so an attacker can't force spire to
|
|
75
108
|
* spend CPU/IO on repeated large-body parses.
|
|
76
109
|
*
|
|
77
|
-
*
|
|
110
|
+
* 200 uploads per minute per IP — generous for a chat client (rapid-
|
|
78
111
|
* fire image attachments) but tight enough to shield the disk.
|
|
79
112
|
*/
|
|
80
113
|
export const uploadLimiter = rateLimit({
|
|
81
114
|
keyGenerator: keyByIp,
|
|
82
115
|
legacyHeaders: false,
|
|
83
|
-
limit:
|
|
116
|
+
limit: 200,
|
|
117
|
+
skip: devApiKeySkipsRateLimits,
|
|
84
118
|
standardHeaders: "draft-7",
|
|
85
119
|
windowMs: 60 * 1000,
|
|
86
120
|
});
|
package/src/server/user.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Database } from "../Database.ts";
|
|
2
|
-
import type winston from "winston";
|
|
3
2
|
|
|
4
3
|
import express from "express";
|
|
5
4
|
|
|
@@ -17,7 +16,6 @@ import { protect } from "./index.ts";
|
|
|
17
16
|
|
|
18
17
|
export const getUserRouter = (
|
|
19
18
|
db: Database,
|
|
20
|
-
log: winston.Logger,
|
|
21
19
|
tokenValidator: (key: string, scope: TokenScopes) => boolean,
|
|
22
20
|
) => {
|
|
23
21
|
const router = express.Router();
|
|
@@ -33,9 +31,13 @@ export const getUserRouter = (
|
|
|
33
31
|
});
|
|
34
32
|
|
|
35
33
|
router.get("/:id/devices", protect, async (req, res) => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
const id = getParam(req, "id");
|
|
35
|
+
const user = await db.retrieveUser(id);
|
|
36
|
+
if (!user) {
|
|
37
|
+
res.sendStatus(404);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const deviceList = await db.retrieveUserDeviceList([id]);
|
|
39
41
|
return res.send(msgpack.encode(deviceList));
|
|
40
42
|
});
|
|
41
43
|
|
|
@@ -98,7 +100,6 @@ export const getUserRouter = (
|
|
|
98
100
|
);
|
|
99
101
|
|
|
100
102
|
if (!token) {
|
|
101
|
-
log.warn("Invalid signature on token.");
|
|
102
103
|
res.sendStatus(400);
|
|
103
104
|
return;
|
|
104
105
|
}
|
|
@@ -110,9 +111,8 @@ export const getUserRouter = (
|
|
|
110
111
|
deviceData,
|
|
111
112
|
);
|
|
112
113
|
res.send(msgpack.encode(device));
|
|
113
|
-
} catch (
|
|
114
|
-
|
|
115
|
-
// failed registration due to signkey being taken
|
|
114
|
+
} catch (_err: unknown) {
|
|
115
|
+
// signkey already taken
|
|
116
116
|
res.sendStatus(470);
|
|
117
117
|
return;
|
|
118
118
|
}
|
package/src/utils/jwtSecret.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Returns the JWT signing secret.
|
|
2
|
+
* Returns the dedicated JWT HMAC signing secret.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* This MUST be a separate key from SPK (the Ed25519 server signing key)
|
|
5
|
+
* so that compromise of one does not affect the other.
|
|
6
6
|
*/
|
|
7
7
|
export function getJwtSecret(): string {
|
|
8
|
-
const secret = process.env["JWT_SECRET"]
|
|
8
|
+
const secret = process.env["JWT_SECRET"];
|
|
9
9
|
if (!secret) {
|
|
10
10
|
throw new Error(
|
|
11
|
-
"
|
|
12
|
-
"Set JWT_SECRET (preferred) or SPK in your environment.",
|
|
11
|
+
"JWT_SECRET is not set. Generate one with: node scripts/gen-spk.js",
|
|
13
12
|
);
|
|
14
13
|
}
|
|
15
14
|
return secret;
|
package/src/utils/loadEnv.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { config } from "dotenv";
|
|
|
3
3
|
/* Populate process.env with vars from .env and verify required vars are present. */
|
|
4
4
|
export function loadEnv(): void {
|
|
5
5
|
config();
|
|
6
|
-
const requiredEnvVars: string[] = ["
|
|
6
|
+
const requiredEnvVars: string[] = ["DB_TYPE", "JWT_SECRET", "SPK"];
|
|
7
7
|
for (const required of requiredEnvVars) {
|
|
8
8
|
if (process.env[required] === undefined) {
|
|
9
9
|
process.stderr.write(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { XUtils } from "@vex-chat/crypto";
|
|
2
|
-
import * as uuid from "uuid";
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import winston from "winston";
|
|
5
|
-
import { Database } from "../Database.js";
|
|
6
|
-
// vi.mock is hoisted above all imports automatically.
|
|
7
|
-
// Minimal stubs for uuid functions used by spire src: v4, parse, stringify.
|
|
8
|
-
vi.mock("uuid", () => ({
|
|
9
|
-
parse: (s) => {
|
|
10
|
-
const matches = s.replace(/-/g, "").match(/.{2}/g);
|
|
11
|
-
if (!matches)
|
|
12
|
-
throw new Error("Invalid UUID");
|
|
13
|
-
return Uint8Array.from(matches.map((b) => parseInt(b, 16)));
|
|
14
|
-
},
|
|
15
|
-
stringify: (b) => {
|
|
16
|
-
const hex = Array.from(b)
|
|
17
|
-
.map((x) => x.toString(16).padStart(2, "0"))
|
|
18
|
-
.join("");
|
|
19
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
20
|
-
},
|
|
21
|
-
v4: vi.fn(() => "93ce482b-a0f2-4f6e-b1df-3aed61073552"),
|
|
22
|
-
validate: () => true,
|
|
23
|
-
}));
|
|
24
|
-
/** Winston logger stub — Database.close() calls `.info`, and `{}` breaks that. */
|
|
25
|
-
function silentLogger() {
|
|
26
|
-
const noop = vi.fn();
|
|
27
|
-
return {
|
|
28
|
-
debug: noop,
|
|
29
|
-
error: noop,
|
|
30
|
-
info: noop,
|
|
31
|
-
log: noop,
|
|
32
|
-
verbose: noop,
|
|
33
|
-
warn: noop,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
describe("Database", () => {
|
|
37
|
-
// Reusable test data
|
|
38
|
-
const keyID = "de459e05-aa63-4dfa-97b4-ed43d5c7a5f7";
|
|
39
|
-
const userID = "4e67b90f-cbf8-44bc-8ce3-d3b248f033f1";
|
|
40
|
-
const deviceID = "23cb0b27-7d0c-43b2-87e1-c2b93e0095e5";
|
|
41
|
-
const publicKey = XUtils.decodeHex("30c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e");
|
|
42
|
-
const signature = XUtils.decodeHex("dd0665079426c3efcf4dce9b1487e4aca132f8147581b3294c3f23ddd2b4ba8240a10082bd06805d7eb320d91af971da3306e11b60073ccc3d829710f5036004000030c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e");
|
|
43
|
-
const testSQLPreKey = {
|
|
44
|
-
deviceID,
|
|
45
|
-
index: 1,
|
|
46
|
-
keyID,
|
|
47
|
-
publicKey: "30c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e",
|
|
48
|
-
signature: "dd0665079426c3efcf4dce9b1487e4aca132f8147581b3294c3f23ddd2b4ba8240a10082bd06805d7eb320d91af971da3306e11b60073ccc3d829710f5036004000030c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e",
|
|
49
|
-
userID,
|
|
50
|
-
};
|
|
51
|
-
const testWSPreKey = {
|
|
52
|
-
deviceID,
|
|
53
|
-
index: 1,
|
|
54
|
-
publicKey,
|
|
55
|
-
signature,
|
|
56
|
-
};
|
|
57
|
-
const options = {
|
|
58
|
-
dbType: "sqlite3mem",
|
|
59
|
-
};
|
|
60
|
-
describe("saveOTK", () => {
|
|
61
|
-
it("takes a userId and one time key, adds a keyId and saves it to oneTimeKey table", async () => {
|
|
62
|
-
expect.assertions(1);
|
|
63
|
-
vi.mocked(uuid.v4).mockReturnValueOnce(keyID);
|
|
64
|
-
vi.spyOn(winston, "createLogger").mockReturnValueOnce(silentLogger());
|
|
65
|
-
const provider = new Database(options);
|
|
66
|
-
await new Promise((resolve, reject) => {
|
|
67
|
-
provider.once("ready", () => {
|
|
68
|
-
void (async () => {
|
|
69
|
-
try {
|
|
70
|
-
await provider.saveOTK(testSQLPreKey.userID, testSQLPreKey.deviceID, [
|
|
71
|
-
{
|
|
72
|
-
deviceID,
|
|
73
|
-
index: 1,
|
|
74
|
-
publicKey,
|
|
75
|
-
signature,
|
|
76
|
-
},
|
|
77
|
-
]);
|
|
78
|
-
const oneTimeKey = await provider.getOTK(deviceID);
|
|
79
|
-
expect(oneTimeKey).toEqual(testWSPreKey);
|
|
80
|
-
await provider.close();
|
|
81
|
-
resolve();
|
|
82
|
-
}
|
|
83
|
-
catch (e) {
|
|
84
|
-
reject(e instanceof Error ? e : new Error(String(e)));
|
|
85
|
-
}
|
|
86
|
-
})();
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
describe("getPreKeys", () => {
|
|
92
|
-
it("returns a preKey by deviceID if said preKey exists.", async () => {
|
|
93
|
-
expect.assertions(1);
|
|
94
|
-
const provider = new Database(options);
|
|
95
|
-
await new Promise((resolve, reject) => {
|
|
96
|
-
provider.once("ready", () => {
|
|
97
|
-
void (async () => {
|
|
98
|
-
try {
|
|
99
|
-
await provider["db"]
|
|
100
|
-
.insertInto("preKeys")
|
|
101
|
-
.values(testSQLPreKey)
|
|
102
|
-
.execute();
|
|
103
|
-
const result = await provider.getPreKeys(deviceID);
|
|
104
|
-
expect(result).toEqual(testWSPreKey);
|
|
105
|
-
await provider.close();
|
|
106
|
-
resolve();
|
|
107
|
-
}
|
|
108
|
-
catch (e) {
|
|
109
|
-
reject(e instanceof Error ? e : new Error(String(e)));
|
|
110
|
-
}
|
|
111
|
-
})();
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
it("return null if there are no preKeys with deviceID param", async () => {
|
|
116
|
-
expect.assertions(1);
|
|
117
|
-
const provider = new Database(options);
|
|
118
|
-
await new Promise((resolve, reject) => {
|
|
119
|
-
provider.once("ready", () => {
|
|
120
|
-
void (async () => {
|
|
121
|
-
try {
|
|
122
|
-
const result = await provider.getPreKeys(deviceID);
|
|
123
|
-
expect(result).toBeNull();
|
|
124
|
-
await provider.close();
|
|
125
|
-
resolve();
|
|
126
|
-
}
|
|
127
|
-
catch (e) {
|
|
128
|
-
reject(e instanceof Error ? e : new Error(String(e)));
|
|
129
|
-
}
|
|
130
|
-
})();
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
//# sourceMappingURL=Database.spec.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Database.spec.js","sourceRoot":"","sources":["../../src/__tests__/Database.spec.ts"],"names":[],"mappings":"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"}
|