cpeak 2.5.0 → 2.7.0
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 +300 -2
- package/dist/index.d.ts +88 -16
- package/dist/index.js +653 -165
- package/dist/index.js.map +1 -1
- package/lib/index.ts +229 -158
- package/lib/internal/compression.ts +180 -0
- package/lib/internal/types.ts +10 -0
- package/lib/types.ts +20 -5
- package/lib/utils/auth.ts +148 -0
- package/lib/utils/cookieParser.ts +179 -0
- package/lib/utils/cors.ts +109 -0
- package/lib/utils/index.ts +15 -1
- package/lib/utils/render.ts +7 -0
- package/lib/utils/serveStatic.ts +16 -5
- package/lib/utils/swagger.ts +31 -0
- package/lib/utils/types.ts +51 -0
- package/package.json +4 -4
- /package/lib/utils/{paseJSON.ts → parseJSON.ts} +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import zlib from "node:zlib";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import type { Transform } from "node:stream";
|
|
6
|
+
import type { ServerResponse } from "node:http";
|
|
7
|
+
import type { CompressionOptions, ResolvedCompressionConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
type Encoding = "br" | "gzip" | "deflate";
|
|
10
|
+
|
|
11
|
+
const COMPRESSIBLE_TYPE = /text|json|javascript|css|xml|svg/i;
|
|
12
|
+
const NO_TRANSFORM = /(?:^|,)\s*no-transform\s*(?:,|$)/i;
|
|
13
|
+
|
|
14
|
+
// Parse Accept-Encoding and pick a compression algorithm the server supports.
|
|
15
|
+
// Handles q=0 to disable an algorithm. Cpeak preference is fixed: br > gzip > deflate.
|
|
16
|
+
function pickEncoding(header: string): Encoding | null {
|
|
17
|
+
if (!header) return null;
|
|
18
|
+
|
|
19
|
+
const accepted: Record<string, number> = {};
|
|
20
|
+
let wildcard: number | undefined;
|
|
21
|
+
|
|
22
|
+
for (const part of header.split(",")) {
|
|
23
|
+
const [rawName, ...params] = part.trim().split(";");
|
|
24
|
+
const name = rawName.trim().toLowerCase();
|
|
25
|
+
if (!name) continue;
|
|
26
|
+
|
|
27
|
+
let q = 1;
|
|
28
|
+
for (const p of params) {
|
|
29
|
+
const m = p.trim().match(/^q=([\d.]+)$/i);
|
|
30
|
+
if (m) q = Number(m[1]);
|
|
31
|
+
}
|
|
32
|
+
if (Number.isNaN(q)) q = 0;
|
|
33
|
+
|
|
34
|
+
if (name === "*") wildcard = q;
|
|
35
|
+
else accepted[name] = q;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tryPick = (enc: Encoding): boolean => {
|
|
39
|
+
const q = enc in accepted ? accepted[enc] : wildcard;
|
|
40
|
+
return q !== undefined && q > 0;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (tryPick("br")) return "br";
|
|
44
|
+
if (tryPick("gzip")) return "gzip";
|
|
45
|
+
if (tryPick("deflate")) return "deflate";
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handling the Vary HTTP header
|
|
50
|
+
function appendVary(res: ServerResponse, value: string) {
|
|
51
|
+
const existing = res.getHeader("Vary");
|
|
52
|
+
if (!existing) return res.setHeader("Vary", value);
|
|
53
|
+
const current = String(existing)
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (
|
|
58
|
+
current.includes("*") ||
|
|
59
|
+
current.some((v) => v.toLowerCase() === value.toLowerCase())
|
|
60
|
+
)
|
|
61
|
+
return;
|
|
62
|
+
res.setHeader("Vary", [...current, value].join(", "));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Brotli options. Zlib uses 11 (max), which is really slow for live
|
|
66
|
+
// responses. We go with 4 unless the developer specifies otherwise.
|
|
67
|
+
function brotliOptsFor(config: ResolvedCompressionConfig): zlib.BrotliOptions {
|
|
68
|
+
const userBrotli = config.brotli || {};
|
|
69
|
+
return {
|
|
70
|
+
...userBrotli,
|
|
71
|
+
params: {
|
|
72
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
|
73
|
+
...(userBrotli.params || {})
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createCompressorStream(
|
|
79
|
+
encoding: Encoding,
|
|
80
|
+
config: ResolvedCompressionConfig
|
|
81
|
+
): Transform {
|
|
82
|
+
if (encoding === "br")
|
|
83
|
+
return zlib.createBrotliCompress(brotliOptsFor(config));
|
|
84
|
+
if (encoding === "gzip") return zlib.createGzip(config.gzip);
|
|
85
|
+
return zlib.createDeflate(config.deflate);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Decides what to do with this response
|
|
89
|
+
function negotiate(
|
|
90
|
+
res: ServerResponse,
|
|
91
|
+
mime: string,
|
|
92
|
+
size: number,
|
|
93
|
+
config: ResolvedCompressionConfig
|
|
94
|
+
): { encoding: Encoding | null; eligible: boolean } {
|
|
95
|
+
// Whether this content type is worth trying to compress at all.
|
|
96
|
+
// Some types are already compressed and don't compress well.
|
|
97
|
+
if (!COMPRESSIBLE_TYPE.test(mime)) return { encoding: null, eligible: false };
|
|
98
|
+
|
|
99
|
+
if (res.req?.method === "HEAD") return { encoding: null, eligible: false };
|
|
100
|
+
|
|
101
|
+
// RFC specification: don't transform responses that ask not to be transformed.
|
|
102
|
+
const cc = res.getHeader("Cache-Control");
|
|
103
|
+
if (cc && NO_TRANSFORM.test(String(cc)))
|
|
104
|
+
return { encoding: null, eligible: false };
|
|
105
|
+
|
|
106
|
+
const existing = res.getHeader("Content-Encoding");
|
|
107
|
+
if (existing && existing !== "identity")
|
|
108
|
+
return { encoding: null, eligible: false };
|
|
109
|
+
|
|
110
|
+
if (size < config.threshold) return { encoding: null, eligible: true };
|
|
111
|
+
|
|
112
|
+
const encoding = pickEncoding(
|
|
113
|
+
String(res.req?.headers["accept-encoding"] || "")
|
|
114
|
+
);
|
|
115
|
+
return { encoding, eligible: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Converts into a Readable stream
|
|
119
|
+
function bodyAsReadable(body: Buffer | string | Readable): Readable {
|
|
120
|
+
if (Buffer.isBuffer(body)) return Readable.from([body]);
|
|
121
|
+
if (typeof body === "string") return Readable.from([Buffer.from(body)]);
|
|
122
|
+
return body;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resolves compression options (or 'true' for defaults) into a
|
|
126
|
+
// complete config. Called once at Cpeak construction.
|
|
127
|
+
export function resolveCompressionOptions(
|
|
128
|
+
input: true | CompressionOptions
|
|
129
|
+
): ResolvedCompressionConfig {
|
|
130
|
+
const options: CompressionOptions = input === true ? {} : input;
|
|
131
|
+
return {
|
|
132
|
+
threshold: options.threshold ?? 1024,
|
|
133
|
+
brotli: options.brotli ?? {},
|
|
134
|
+
gzip: options.gzip ?? {},
|
|
135
|
+
deflate: options.deflate ?? {}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The final point used by res.compress, res.json, res.sendFile and res.render
|
|
140
|
+
// when compression is enabled by the developer.
|
|
141
|
+
//
|
|
142
|
+
// Compression always goes through createGzip/createBrotliCompress/createDeflate
|
|
143
|
+
// streams which are async and run on libuv's thread pool.
|
|
144
|
+
export async function compressAndSend(
|
|
145
|
+
res: ServerResponse,
|
|
146
|
+
mime: string,
|
|
147
|
+
body: Buffer | string | Readable,
|
|
148
|
+
config: ResolvedCompressionConfig,
|
|
149
|
+
size?: number
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
res.setHeader("Content-Type", mime);
|
|
152
|
+
|
|
153
|
+
const knownSize: number = Buffer.isBuffer(body)
|
|
154
|
+
? body.length
|
|
155
|
+
: typeof body === "string"
|
|
156
|
+
? Buffer.byteLength(body)
|
|
157
|
+
: (size ?? Infinity);
|
|
158
|
+
|
|
159
|
+
const { encoding, eligible } = negotiate(res, mime, knownSize, config);
|
|
160
|
+
|
|
161
|
+
if (!encoding) {
|
|
162
|
+
if (eligible) appendVary(res, "Accept-Encoding");
|
|
163
|
+
if (Buffer.isBuffer(body) || typeof body === "string") {
|
|
164
|
+
res.setHeader("Content-Length", String(knownSize));
|
|
165
|
+
res.end(body);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (size !== undefined) res.setHeader("Content-Length", String(size));
|
|
169
|
+
await pipeline(body, res);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
res.setHeader("Content-Encoding", encoding);
|
|
174
|
+
appendVary(res, "Accept-Encoding");
|
|
175
|
+
await pipeline(
|
|
176
|
+
bodyAsReadable(body),
|
|
177
|
+
createCompressorStream(encoding, config),
|
|
178
|
+
res
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CompressionOptions {
|
|
2
|
+
// Responses with a Content-Length below this many bytes are sent uncompressed.
|
|
3
|
+
// Below ~1KB, TCP/TLS framing overhead can outweigh the savings.
|
|
4
|
+
threshold?: number;
|
|
5
|
+
brotli?: import("node:zlib").BrotliOptions;
|
|
6
|
+
gzip?: import("node:zlib").ZlibOptions;
|
|
7
|
+
deflate?: import("node:zlib").ZlibOptions;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ResolvedCompressionConfig = Required<CompressionOptions>;
|
package/lib/types.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import type { Buffer } from "node:buffer";
|
|
4
|
+
import type { CompressionOptions } from "./internal/types";
|
|
3
5
|
|
|
4
|
-
export type Cpeak
|
|
6
|
+
export type { Cpeak } from "./index";
|
|
7
|
+
|
|
8
|
+
// For constructor options passed to `cpeak()`
|
|
9
|
+
export interface CpeakOptions {
|
|
10
|
+
compression?: boolean | CompressionOptions;
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
// Extending Node.js's Request and Response objects to add our custom properties
|
|
7
14
|
export type StringMap = Record<string, string>;
|
|
@@ -12,16 +19,24 @@ export interface CpeakRequest<
|
|
|
12
19
|
> extends IncomingMessage {
|
|
13
20
|
params: StringMap;
|
|
14
21
|
query: ReqQueries;
|
|
15
|
-
// vars?: StringMap;
|
|
16
22
|
body?: ReqBody;
|
|
23
|
+
cookies?: StringMap;
|
|
24
|
+
signedCookies?: Record<string, string | false>;
|
|
17
25
|
[key: string]: any; // allow developers to add their onw extensions (e.g. req.test)
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export interface CpeakResponse extends ServerResponse {
|
|
21
29
|
sendFile: (path: string, mime: string) => Promise<void>;
|
|
22
30
|
status: (code: number) => CpeakResponse;
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
attachment: (filename?: string) => CpeakResponse;
|
|
32
|
+
cookie: (name: string, value: string, options?: any) => CpeakResponse;
|
|
33
|
+
redirect: (location: string) => void;
|
|
34
|
+
json: (data: any) => void | Promise<void>; // sync when compression is off, async when enabled
|
|
35
|
+
compress: (
|
|
36
|
+
mime: string,
|
|
37
|
+
body: Buffer | string | Readable,
|
|
38
|
+
size?: number
|
|
39
|
+
) => Promise<void>;
|
|
25
40
|
[key: string]: any; // allow developers to add their onw extensions (e.g. res.test)
|
|
26
41
|
}
|
|
27
42
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomBytes, pbkdf2, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type { Middleware } from "../types";
|
|
4
|
+
import { frameworkError, ErrorCode } from "../index";
|
|
5
|
+
import type { AuthOptions, PbkdfOptions } from "./types";
|
|
6
|
+
|
|
7
|
+
const pbkdf2Async = promisify(pbkdf2);
|
|
8
|
+
|
|
9
|
+
const DEFAULTS = {
|
|
10
|
+
iterations: 210_000,
|
|
11
|
+
keylen: 64,
|
|
12
|
+
digest: "sha512",
|
|
13
|
+
saltSize: 32,
|
|
14
|
+
hmacAlgorithm: "sha256",
|
|
15
|
+
tokenIdSize: 20,
|
|
16
|
+
tokenExpiry: 7 * 24 * 60 * 60 * 1000 // 7 days in ms
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export async function hashPassword(
|
|
20
|
+
password: string,
|
|
21
|
+
options?: PbkdfOptions
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const iterations = options?.iterations ?? DEFAULTS.iterations;
|
|
24
|
+
const keylen = options?.keylen ?? DEFAULTS.keylen;
|
|
25
|
+
const digest = options?.digest ?? DEFAULTS.digest;
|
|
26
|
+
const saltSize = options?.saltSize ?? DEFAULTS.saltSize;
|
|
27
|
+
const salt = randomBytes(saltSize);
|
|
28
|
+
const hash = await pbkdf2Async(password, salt, iterations, keylen, digest);
|
|
29
|
+
return `pbkdf2:${iterations}:${keylen}:${digest}:${salt.toString("hex")}:${hash.toString("hex")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function verifyPassword(
|
|
33
|
+
password: string,
|
|
34
|
+
stored: string
|
|
35
|
+
): Promise<boolean> {
|
|
36
|
+
// When argon2 is added, dispatch on the prefix here.
|
|
37
|
+
const withoutPrefix = stored.slice(stored.indexOf(":") + 1);
|
|
38
|
+
const parts = withoutPrefix.split(":");
|
|
39
|
+
if (parts.length !== 5) return false;
|
|
40
|
+
const [itersStr, keylenStr, digest, saltHex, hashHex] = parts;
|
|
41
|
+
const iterations = parseInt(itersStr, 10);
|
|
42
|
+
const keylen = parseInt(keylenStr, 10);
|
|
43
|
+
if (!digest || !saltHex || !hashHex || isNaN(iterations) || isNaN(keylen))
|
|
44
|
+
return false;
|
|
45
|
+
const salt = Buffer.from(saltHex, "hex");
|
|
46
|
+
const hash = await pbkdf2Async(password, salt, iterations, keylen, digest);
|
|
47
|
+
const storedHash = Buffer.from(hashHex, "hex");
|
|
48
|
+
if (storedHash.length !== hash.length) return false;
|
|
49
|
+
return timingSafeEqual(hash, storedHash);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function signToken(tokenId: string, secret: string, algorithm: string): string {
|
|
53
|
+
const sig = createHmac(algorithm, secret).update(tokenId).digest("hex");
|
|
54
|
+
return `${tokenId}.${sig}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractTokenId(
|
|
58
|
+
token: string,
|
|
59
|
+
secret: string,
|
|
60
|
+
algorithm: string
|
|
61
|
+
): string | null {
|
|
62
|
+
const dot = token.indexOf(".");
|
|
63
|
+
if (dot === -1) return null;
|
|
64
|
+
const tokenId = token.slice(0, dot);
|
|
65
|
+
const sig = token.slice(dot + 1);
|
|
66
|
+
const expected = createHmac(algorithm, secret).update(tokenId).digest("hex");
|
|
67
|
+
const expectedBuf = Buffer.from(expected, "hex");
|
|
68
|
+
const actualBuf = Buffer.from(sig, "hex");
|
|
69
|
+
if (expectedBuf.length !== actualBuf.length) return null;
|
|
70
|
+
if (!timingSafeEqual(expectedBuf, actualBuf)) return null;
|
|
71
|
+
return tokenId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function auth(options: AuthOptions): Middleware {
|
|
75
|
+
if (!options.secret || options.secret.length < 32) {
|
|
76
|
+
throw frameworkError(
|
|
77
|
+
"Secret must be at least 32 characters. HMAC security is only as strong as the key.",
|
|
78
|
+
auth,
|
|
79
|
+
ErrorCode.WEAK_SECRET
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const {
|
|
84
|
+
secret,
|
|
85
|
+
saveToken,
|
|
86
|
+
findToken,
|
|
87
|
+
revokeToken,
|
|
88
|
+
tokenExpiry = DEFAULTS.tokenExpiry,
|
|
89
|
+
hmacAlgorithm = DEFAULTS.hmacAlgorithm,
|
|
90
|
+
tokenIdSize = DEFAULTS.tokenIdSize
|
|
91
|
+
} = options;
|
|
92
|
+
|
|
93
|
+
const pbkdfOpts: PbkdfOptions = {
|
|
94
|
+
iterations: options.iterations,
|
|
95
|
+
keylen: options.keylen,
|
|
96
|
+
digest: options.digest,
|
|
97
|
+
saltSize: options.saltSize
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const _hashPassword = ({ password }: { password: string }) =>
|
|
101
|
+
hashPassword(password, pbkdfOpts);
|
|
102
|
+
|
|
103
|
+
const login = async ({
|
|
104
|
+
password,
|
|
105
|
+
hashedPassword,
|
|
106
|
+
userId
|
|
107
|
+
}: {
|
|
108
|
+
password: string;
|
|
109
|
+
hashedPassword: string;
|
|
110
|
+
userId: string;
|
|
111
|
+
}): Promise<string | null> => {
|
|
112
|
+
const isMatch = await verifyPassword(password, hashedPassword);
|
|
113
|
+
if (!isMatch) return null;
|
|
114
|
+
const tokenId = randomBytes(tokenIdSize).toString("hex");
|
|
115
|
+
const token = signToken(tokenId, secret, hmacAlgorithm);
|
|
116
|
+
await saveToken(tokenId, userId, new Date(Date.now() + tokenExpiry));
|
|
117
|
+
return token;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const verifyToken = async (
|
|
121
|
+
token: string
|
|
122
|
+
): Promise<{ userId: string } | null> => {
|
|
123
|
+
if (!token) return null;
|
|
124
|
+
const tokenId = extractTokenId(token, secret, hmacAlgorithm);
|
|
125
|
+
if (!tokenId) return null;
|
|
126
|
+
const record = await findToken(tokenId);
|
|
127
|
+
if (!record) return null;
|
|
128
|
+
if (new Date(record.expiresAt) < new Date()) return null;
|
|
129
|
+
return { userId: record.userId };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const logout = revokeToken
|
|
133
|
+
? async (token: string): Promise<boolean> => {
|
|
134
|
+
const tokenId = extractTokenId(token, secret, hmacAlgorithm);
|
|
135
|
+
if (!tokenId) return false;
|
|
136
|
+
await revokeToken(tokenId);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
: undefined;
|
|
140
|
+
|
|
141
|
+
return (req, _res, next) => {
|
|
142
|
+
req.hashPassword = _hashPassword;
|
|
143
|
+
req.login = login;
|
|
144
|
+
req.verifyToken = verifyToken;
|
|
145
|
+
if (logout) req.logout = logout;
|
|
146
|
+
next();
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { CpeakRequest, CpeakResponse, Next } from "../types";
|
|
3
|
+
import { frameworkError, ErrorCode } from "../index";
|
|
4
|
+
import type { CookieOptions } from "./types";
|
|
5
|
+
|
|
6
|
+
// This will sign the cookie value with HMAC with the secret.
|
|
7
|
+
// Ideal for data like user IDs or session IDs, where you want to ensure the integrity of the cookie value without encryption.
|
|
8
|
+
// So this way, imagine you save a user ID in the cookie. By signing it, you can detect if the client has tampered with the cookie value
|
|
9
|
+
// (e.g., changing the user ID to impersonate another user).
|
|
10
|
+
// However, since it's not encrypted, the actual user ID is still visible to the client.
|
|
11
|
+
// This is a common approach for session cookies where you want to prevent tampering but don't mind if the value is visible.
|
|
12
|
+
function sign(value: string, secret: string): string {
|
|
13
|
+
const sig = createHmac("sha256", secret).update(value).digest("base64url");
|
|
14
|
+
return `s:${value}.${sig}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function unsign(signed: string, secret: string): string | false {
|
|
18
|
+
if (!signed.startsWith("s:")) return false;
|
|
19
|
+
const withoutPrefix = signed.slice(2);
|
|
20
|
+
const lastDot = withoutPrefix.lastIndexOf(".");
|
|
21
|
+
if (lastDot === -1) return false;
|
|
22
|
+
const value = withoutPrefix.slice(0, lastDot);
|
|
23
|
+
const sig = withoutPrefix.slice(lastDot + 1);
|
|
24
|
+
const expected = createHmac("sha256", secret)
|
|
25
|
+
.update(value)
|
|
26
|
+
.digest("base64url");
|
|
27
|
+
const expectedBuf = Buffer.from(expected);
|
|
28
|
+
const actualBuf = Buffer.from(sig);
|
|
29
|
+
if (expectedBuf.length !== actualBuf.length) return false;
|
|
30
|
+
if (!timingSafeEqual(expectedBuf, actualBuf)) return false;
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parses the raw value of an HTTP `Cookie` request header into a name->value
|
|
35
|
+
// This should be compatible with the RFC 6265 HTTP specification
|
|
36
|
+
function parseRawCookies(header: string): Record<string, string> {
|
|
37
|
+
// Use a null-prototype object to prevent prototype pollution attacks when assigning cookie names like "__proto__" or "constructor".
|
|
38
|
+
const cookies: Record<string, string> = Object.create(null);
|
|
39
|
+
if (!header) return cookies;
|
|
40
|
+
|
|
41
|
+
const pairs = header.split(";");
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
44
|
+
const pair = pairs[i];
|
|
45
|
+
const equalSignIndex = pair.indexOf("=");
|
|
46
|
+
|
|
47
|
+
// RFC 6265: cookie-pair requires '='. Pairs without one (e.g. a
|
|
48
|
+
// bare flag like `Cookie: foo`) are not valid cookie-pairs and we skip them.
|
|
49
|
+
// Note we use the FIRST '=' only. So values like base64 padding (`token=YWJjPT0=`) must keep trailing '='s.
|
|
50
|
+
if (equalSignIndex === -1) continue;
|
|
51
|
+
|
|
52
|
+
const key = pair.slice(0, equalSignIndex).trim();
|
|
53
|
+
// Drop empty names and honour the FIRST occurrence on duplicates (Specs say servers SHOULD NOT rely on order.
|
|
54
|
+
// We pick first-wins for stability).
|
|
55
|
+
if (!key || cookies[key] !== undefined) continue;
|
|
56
|
+
|
|
57
|
+
let val = pair.slice(equalSignIndex + 1).trim();
|
|
58
|
+
|
|
59
|
+
// Cookie values are sometimes sent wrapped in double quotes (like name="hello world"), so we strip the outer "
|
|
60
|
+
// characters to get the actual value hello world.
|
|
61
|
+
// The val.length > 1 guard handles the edge case where the value is literally just a single " — without it, that one
|
|
62
|
+
// character would match both the "starts with quote" and "ends with quote" checks, and slice(1, -1) would wipe it out
|
|
63
|
+
// to an empty string.
|
|
64
|
+
if (val.length > 1 && val[0] === '"' && val[val.length - 1] === '"') {
|
|
65
|
+
val = val.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Percent-decoding cookie values is a server-side convention (not part of
|
|
69
|
+
// RFC 6265 itself), but it's what Express and most ecosystem libraries do,
|
|
70
|
+
// so we follow suit for compatibility. Skip the decode entirely when there's no
|
|
71
|
+
// '%' to save work on the common case, and fall back to the raw value if
|
|
72
|
+
// decodeURIComponent throws on malformed input rather than crashing the
|
|
73
|
+
// whole request.
|
|
74
|
+
try {
|
|
75
|
+
cookies[key] = val.indexOf("%") !== -1 ? decodeURIComponent(val) : val;
|
|
76
|
+
} catch (e) {
|
|
77
|
+
cookies[key] = val;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return cookies;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// One example output: "session=abc123; Path=/dashboard; Domain=example.com; Max-Age=86400; Expires=Thu, 31 Dec 2026 00:00:00 GMT; HttpOnly; Secure; SameSite=Strict"
|
|
84
|
+
function buildSetCookieHeader(
|
|
85
|
+
name: string,
|
|
86
|
+
value: string,
|
|
87
|
+
options: CookieOptions
|
|
88
|
+
): string {
|
|
89
|
+
const parts: string[] = [`${name}=${encodeURIComponent(value)}`];
|
|
90
|
+
const path = options.path ?? "/";
|
|
91
|
+
parts.push(`Path=${path}`);
|
|
92
|
+
if (options.domain) parts.push(`Domain=${options.domain}`);
|
|
93
|
+
if (options.maxAge !== undefined)
|
|
94
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge / 1000)}`);
|
|
95
|
+
if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
96
|
+
if (options.httpOnly) parts.push("HttpOnly");
|
|
97
|
+
if (options.secure) parts.push("Secure");
|
|
98
|
+
if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
|
|
99
|
+
return parts.join("; ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Without this helper, calling res.cookie("a", "1") then res.cookie("b", "2") would overwrite the first cookie instead
|
|
103
|
+
// of sending both.
|
|
104
|
+
function appendSetCookie(res: CpeakResponse, header: string) {
|
|
105
|
+
const existing = res.getHeader("Set-Cookie");
|
|
106
|
+
if (!existing) {
|
|
107
|
+
res.setHeader("Set-Cookie", [header]);
|
|
108
|
+
} else if (Array.isArray(existing)) {
|
|
109
|
+
res.setHeader("Set-Cookie", [...existing, header]);
|
|
110
|
+
} else {
|
|
111
|
+
res.setHeader("Set-Cookie", [String(existing), header]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function cookieParser(options: { secret?: string } = {}) {
|
|
116
|
+
const { secret } = options;
|
|
117
|
+
|
|
118
|
+
if (secret !== undefined && secret.length < 32) {
|
|
119
|
+
throw frameworkError(
|
|
120
|
+
"Secret must be at least 32 characters. HMAC security is only as strong as the key.",
|
|
121
|
+
cookieParser,
|
|
122
|
+
ErrorCode.WEAK_SECRET
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (req: CpeakRequest, res: CpeakResponse, next: Next) => {
|
|
127
|
+
const rawHeader = req.headers["cookie"] || "";
|
|
128
|
+
const raw = parseRawCookies(rawHeader);
|
|
129
|
+
|
|
130
|
+
// Mirror parseRawCookies and use null-prototype maps here too. If we used
|
|
131
|
+
// a regular `{}`, the assignment below would invoke Object.prototype's
|
|
132
|
+
// __proto__ setter (no-op for string values), silently dropping any
|
|
133
|
+
// cookie literally named __proto__ — undoing the fix in parseRawCookies.
|
|
134
|
+
const cookies: Record<string, string> = Object.create(null);
|
|
135
|
+
const signedCookies: Record<string, string | false> = Object.create(null);
|
|
136
|
+
|
|
137
|
+
for (const [key, val] of Object.entries(raw)) {
|
|
138
|
+
// The "s:" prefix is the marker we add in `sign()` for HMAC-signed
|
|
139
|
+
// cookies. Route those through unsign so the handler sees the original
|
|
140
|
+
// value (or `false` if the signature didn't verify).
|
|
141
|
+
if (val.startsWith("s:") && secret) {
|
|
142
|
+
signedCookies[key] = unsign(val, secret);
|
|
143
|
+
} else {
|
|
144
|
+
cookies[key] = val;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The separation is intentional signal: "these were signed and verified, trust them more."
|
|
149
|
+
req.cookies = cookies;
|
|
150
|
+
req.signedCookies = signedCookies;
|
|
151
|
+
|
|
152
|
+
res.cookie = (name: string, value: string, options: CookieOptions = {}) => {
|
|
153
|
+
let finalValue = value;
|
|
154
|
+
if (options.signed) {
|
|
155
|
+
if (!secret)
|
|
156
|
+
throw new Error(
|
|
157
|
+
"cookieParser: secret is required to use signed cookies"
|
|
158
|
+
);
|
|
159
|
+
finalValue = sign(value, secret);
|
|
160
|
+
}
|
|
161
|
+
appendSetCookie(res, buildSetCookieHeader(name, finalValue, options));
|
|
162
|
+
return res;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
res.clearCookie = (name: string, options: CookieOptions = {}) => {
|
|
166
|
+
appendSetCookie(
|
|
167
|
+
res,
|
|
168
|
+
buildSetCookieHeader(name, "", {
|
|
169
|
+
...options,
|
|
170
|
+
maxAge: 0,
|
|
171
|
+
expires: new Date(0)
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
return res;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
next();
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { CpeakRequest, CpeakResponse, Next } from "../types";
|
|
2
|
+
import type { CorsOptions, OriginInput } from "./types";
|
|
3
|
+
|
|
4
|
+
// Append a value to an existing header without overwriting prior entries
|
|
5
|
+
// (e.g. compression already sets `Vary: Accept-Encoding`).
|
|
6
|
+
function appendVary(res: CpeakResponse, value: string) {
|
|
7
|
+
const existing = res.getHeader("Vary");
|
|
8
|
+
if (!existing) return res.setHeader("Vary", value);
|
|
9
|
+
const current = String(existing)
|
|
10
|
+
.split(",")
|
|
11
|
+
.map((s) => s.trim())
|
|
12
|
+
.filter(Boolean);
|
|
13
|
+
if (current.includes("*") || current.includes(value)) return;
|
|
14
|
+
res.setHeader("Vary", [...current, value].join(", "));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Determine if the given origin is allowed based on the rule.
|
|
18
|
+
// Examples of what developers can specify for the rule:
|
|
19
|
+
// - `true` or `*`: allow all origins
|
|
20
|
+
// - `false`: disallow all origins
|
|
21
|
+
// - `"https://example.com"`: allow only this origin
|
|
22
|
+
// - `["https://example.com", "https://foo.com"]`: allow these origins
|
|
23
|
+
// - `/\.example\.com$/`: allow origins that match this regex
|
|
24
|
+
// - `(origin) => origin === "https://example.com"`: custom function to determine if the origin is allowed
|
|
25
|
+
async function isAllowed(
|
|
26
|
+
origin: string | undefined,
|
|
27
|
+
rule: OriginInput
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
if (rule === true || rule === "*") return true;
|
|
30
|
+
if (rule === false || !origin) return false;
|
|
31
|
+
if (typeof rule === "string") return rule === origin;
|
|
32
|
+
if (Array.isArray(rule)) return rule.includes(origin);
|
|
33
|
+
if (rule instanceof RegExp) return rule.test(origin);
|
|
34
|
+
if (typeof rule === "function") return await rule(origin);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cors = (options: CorsOptions = {}) => {
|
|
39
|
+
const {
|
|
40
|
+
origin = "*",
|
|
41
|
+
methods = "GET,HEAD,PUT,PATCH,POST,DELETE",
|
|
42
|
+
allowedHeaders,
|
|
43
|
+
exposedHeaders,
|
|
44
|
+
credentials = false,
|
|
45
|
+
maxAge = 86400,
|
|
46
|
+
preflightContinue = false,
|
|
47
|
+
optionsSuccessStatus = 204
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const methodsStr = Array.isArray(methods) ? methods.join(",") : methods;
|
|
51
|
+
const allowedHeadersStr = Array.isArray(allowedHeaders)
|
|
52
|
+
? allowedHeaders.join(",")
|
|
53
|
+
: allowedHeaders;
|
|
54
|
+
const exposedHeadersStr = Array.isArray(exposedHeaders)
|
|
55
|
+
? exposedHeaders.join(",")
|
|
56
|
+
: exposedHeaders;
|
|
57
|
+
|
|
58
|
+
return async (req: CpeakRequest, res: CpeakResponse, next: Next) => {
|
|
59
|
+
const requestOrigin = req.headers.origin;
|
|
60
|
+
|
|
61
|
+
// Not a CORS request, nothing to do.
|
|
62
|
+
if (!requestOrigin) return next();
|
|
63
|
+
|
|
64
|
+
const allowed = await isAllowed(requestOrigin, origin);
|
|
65
|
+
if (!allowed) return next();
|
|
66
|
+
|
|
67
|
+
// We cannot combine Access-Control-Allow-Origin: * with
|
|
68
|
+
// Access-Control-Allow-Credentials: true. Browsers will flat-out reject it.
|
|
69
|
+
// Instead we'll reflect the origin.
|
|
70
|
+
const allowOriginValue =
|
|
71
|
+
origin === "*" && !credentials ? "*" : requestOrigin;
|
|
72
|
+
res.setHeader("Access-Control-Allow-Origin", allowOriginValue);
|
|
73
|
+
if (allowOriginValue !== "*") appendVary(res, "Origin");
|
|
74
|
+
|
|
75
|
+
if (credentials) res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
76
|
+
if (exposedHeadersStr)
|
|
77
|
+
res.setHeader("Access-Control-Expose-Headers", exposedHeadersStr);
|
|
78
|
+
|
|
79
|
+
const isPreflight =
|
|
80
|
+
req.method === "OPTIONS" &&
|
|
81
|
+
req.headers["access-control-request-method"] !== undefined;
|
|
82
|
+
|
|
83
|
+
if (!isPreflight) return next();
|
|
84
|
+
|
|
85
|
+
res.setHeader("Access-Control-Allow-Methods", methodsStr);
|
|
86
|
+
|
|
87
|
+
if (allowedHeadersStr) {
|
|
88
|
+
res.setHeader("Access-Control-Allow-Headers", allowedHeadersStr);
|
|
89
|
+
} else if (origin === "*") {
|
|
90
|
+
// If origin is *, just act like an echo chamber for requested headers. Give back whatever the browser asks for.
|
|
91
|
+
const requested = req.headers["access-control-request-headers"];
|
|
92
|
+
if (requested) res.setHeader("Access-Control-Allow-Headers", requested);
|
|
93
|
+
} else {
|
|
94
|
+
res.setHeader(
|
|
95
|
+
"Access-Control-Allow-Headers",
|
|
96
|
+
"Content-Type, Authorization"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
res.setHeader("Access-Control-Max-Age", String(maxAge));
|
|
101
|
+
|
|
102
|
+
if (preflightContinue) return next();
|
|
103
|
+
|
|
104
|
+
res.statusCode = optionsSuccessStatus;
|
|
105
|
+
res.end();
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export { cors };
|
package/lib/utils/index.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { parseJSON } from "./parseJSON";
|
|
2
2
|
import { serveStatic } from "./serveStatic";
|
|
3
3
|
import { render } from "./render";
|
|
4
|
+
import { swagger } from "./swagger";
|
|
5
|
+
import { auth, hashPassword, verifyPassword } from "./auth";
|
|
6
|
+
import { cookieParser } from "./cookieParser";
|
|
7
|
+
import { cors } from "./cors";
|
|
4
8
|
|
|
5
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
serveStatic,
|
|
11
|
+
parseJSON,
|
|
12
|
+
render,
|
|
13
|
+
swagger,
|
|
14
|
+
auth,
|
|
15
|
+
hashPassword,
|
|
16
|
+
verifyPassword,
|
|
17
|
+
cookieParser,
|
|
18
|
+
cors
|
|
19
|
+
};
|