@fluixi/api 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/client.cjs +103 -0
- package/dist/client.d.ts +95 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +87 -0
- package/dist/client.mjs +82 -0
- package/dist/errors.cjs +63 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +55 -0
- package/dist/errors.mjs +42 -0
- package/dist/index.cjs +667 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.mjs +5442 -0
- package/dist/input.cjs +91 -0
- package/dist/input.d.ts +15 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +73 -0
- package/dist/input.mjs +68 -0
- package/dist/middleware.cjs +41 -0
- package/dist/middleware.d.ts +9 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +19 -0
- package/dist/middleware.mjs +20 -0
- package/dist/openapi.cjs +282 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +312 -0
- package/dist/openapi.mjs +259 -0
- package/dist/rate-limit.cjs +158 -0
- package/dist/rate-limit.d.ts +69 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +121 -0
- package/dist/rate-limit.mjs +4935 -0
- package/dist/route.cjs +192 -0
- package/dist/route.d.ts +97 -0
- package/dist/route.d.ts.map +1 -0
- package/dist/route.js +98 -0
- package/dist/route.mjs +169 -0
- package/dist/security.cjs +222 -0
- package/dist/security.d.ts +64 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +135 -0
- package/dist/security.mjs +4997 -0
- package/dist/standard-schema.cjs +18 -0
- package/dist/standard-schema.d.ts +39 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +6 -0
- package/dist/standard-schema.mjs +0 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +54 -3
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/security.ts
|
|
21
|
+
var security_exports = {};
|
|
22
|
+
__export(security_exports, {
|
|
23
|
+
DEFAULT_SECURITY_HEADERS: () => DEFAULT_SECURITY_HEADERS,
|
|
24
|
+
apiSecurity: () => apiSecurity,
|
|
25
|
+
cors: () => cors,
|
|
26
|
+
originCheck: () => originCheck,
|
|
27
|
+
securityHeaders: () => securityHeaders
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(security_exports);
|
|
30
|
+
|
|
31
|
+
// src/errors.ts
|
|
32
|
+
var ApiError = class extends Error {
|
|
33
|
+
constructor(status, code, message, details) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "ApiError";
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.details = details;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function errorResponse(error) {
|
|
42
|
+
const body = { error: { code: error.code, message: error.message } };
|
|
43
|
+
if (error.details !== void 0) body.error.details = error.details;
|
|
44
|
+
return new Response(JSON.stringify(body), {
|
|
45
|
+
status: error.status,
|
|
46
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/middleware.ts
|
|
51
|
+
function withHeaders(response, set) {
|
|
52
|
+
try {
|
|
53
|
+
set(response.headers);
|
|
54
|
+
return response;
|
|
55
|
+
} catch {
|
|
56
|
+
const copy = new Response(response.body, response);
|
|
57
|
+
set(copy.headers);
|
|
58
|
+
return copy;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function appendVary(headers, name) {
|
|
62
|
+
const vary = headers.get("vary");
|
|
63
|
+
if (!vary) headers.set("vary", name);
|
|
64
|
+
else if (!vary.split(",").some((v) => v.trim().toLowerCase() === name.toLowerCase())) headers.set("vary", `${vary}, ${name}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/rate-limit.ts
|
|
68
|
+
var import_rate_limiter_flexible = require("rate-limiter-flexible");
|
|
69
|
+
function memoryRateLimitStore() {
|
|
70
|
+
return storeOf(async (limit, windowSeconds) => new import_rate_limiter_flexible.RateLimiterMemory({ points: limit, duration: windowSeconds }));
|
|
71
|
+
}
|
|
72
|
+
function storeOf(make, onHit) {
|
|
73
|
+
const limiters = /* @__PURE__ */ new Map();
|
|
74
|
+
return {
|
|
75
|
+
async hit(key, limit, windowSeconds) {
|
|
76
|
+
const id = `${limit}/${windowSeconds}`;
|
|
77
|
+
let pending = limiters.get(id);
|
|
78
|
+
if (!pending) {
|
|
79
|
+
limiters.set(id, pending = make(limit, windowSeconds));
|
|
80
|
+
pending.catch(() => limiters.delete(id));
|
|
81
|
+
}
|
|
82
|
+
const limiter = await pending;
|
|
83
|
+
onHit?.(limiter);
|
|
84
|
+
try {
|
|
85
|
+
const res = await limiter.consume(key);
|
|
86
|
+
return { allowed: true, remaining: res.remainingPoints, resetMs: res.msBeforeNext };
|
|
87
|
+
} catch (thrown) {
|
|
88
|
+
if (thrown instanceof import_rate_limiter_flexible.RateLimiterRes) return { allowed: false, remaining: 0, resetMs: thrown.msBeforeNext };
|
|
89
|
+
throw thrown;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
var ADDRESSES = /* @__PURE__ */ Symbol.for("fluixi.clientAddress");
|
|
95
|
+
var sharedMemory = memoryRateLimitStore();
|
|
96
|
+
var created = 0;
|
|
97
|
+
function clientAddressOf(request) {
|
|
98
|
+
return globalThis[ADDRESSES]?.get(request);
|
|
99
|
+
}
|
|
100
|
+
function rateLimit(options) {
|
|
101
|
+
const { match, methods, limit, window, key = clientAddressOf, store = sharedMemory } = options;
|
|
102
|
+
const applies = matcher(match);
|
|
103
|
+
const methodSet = methods && new Set(methods.map((m) => m.toUpperCase()));
|
|
104
|
+
const bucket = options.name ?? `limit${++created}`;
|
|
105
|
+
return async (request, next) => {
|
|
106
|
+
if (methodSet && !methodSet.has(request.method)) return next();
|
|
107
|
+
if (!applies(request)) return next();
|
|
108
|
+
const who = await key(request);
|
|
109
|
+
if (!who) return next();
|
|
110
|
+
const { allowed, remaining, resetMs } = await store.hit(`${bucket}:${who}`, limit, window);
|
|
111
|
+
const reset = String(Math.ceil(resetMs / 1e3));
|
|
112
|
+
const set = (headers) => {
|
|
113
|
+
headers.set("ratelimit-limit", String(limit));
|
|
114
|
+
headers.set("ratelimit-remaining", String(remaining));
|
|
115
|
+
headers.set("ratelimit-reset", reset);
|
|
116
|
+
};
|
|
117
|
+
if (!allowed) {
|
|
118
|
+
const refused = errorResponse(new ApiError(429, "rate_limited", "Too many requests. Try again later."));
|
|
119
|
+
refused.headers.set("retry-after", reset);
|
|
120
|
+
set(refused.headers);
|
|
121
|
+
return refused;
|
|
122
|
+
}
|
|
123
|
+
return withHeaders(await next(), set);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function matcher(match) {
|
|
127
|
+
if (!match) return () => true;
|
|
128
|
+
if (typeof match === "function") return match;
|
|
129
|
+
const exact = new Set(match.filter((p) => !p.endsWith("/*")));
|
|
130
|
+
const prefixes = match.filter((p) => p.endsWith("/*")).map((p) => p.slice(0, -1));
|
|
131
|
+
return (request) => {
|
|
132
|
+
const { pathname } = new URL(request.url);
|
|
133
|
+
return exact.has(pathname) || prefixes.some((p) => pathname.startsWith(p));
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/security.ts
|
|
138
|
+
var allows = (origins, origin) => typeof origins === "function" ? origins(origin) : origins.includes(origin);
|
|
139
|
+
var DEFAULT_SECURITY_HEADERS = {
|
|
140
|
+
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
|
141
|
+
"cross-origin-opener-policy": "same-origin",
|
|
142
|
+
"cross-origin-resource-policy": "same-origin",
|
|
143
|
+
"referrer-policy": "no-referrer",
|
|
144
|
+
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
|
145
|
+
"x-content-type-options": "nosniff",
|
|
146
|
+
"x-frame-options": "DENY",
|
|
147
|
+
"x-permitted-cross-domain-policies": "none",
|
|
148
|
+
"cache-control": "no-store"
|
|
149
|
+
};
|
|
150
|
+
function securityHeaders(options = {}) {
|
|
151
|
+
const merged = { ...DEFAULT_SECURITY_HEADERS, ...options };
|
|
152
|
+
const entries = Object.entries(merged).filter((e) => e[1] !== false);
|
|
153
|
+
return async (request, next) => withHeaders(await next(), (headers) => {
|
|
154
|
+
for (const [name, value] of entries) if (!headers.has(name)) headers.set(name, value);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
var METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
|
158
|
+
function cors(options) {
|
|
159
|
+
const { origins, credentials = true, methods = METHODS, allowHeaders, exposeHeaders, maxAge = 600 } = options;
|
|
160
|
+
return async (request, next) => {
|
|
161
|
+
const origin = request.headers.get("origin");
|
|
162
|
+
if (!origin || origin === new URL(request.url).origin) return next();
|
|
163
|
+
const allowed = allows(origins, origin);
|
|
164
|
+
const preflight = request.method === "OPTIONS" && request.headers.has("access-control-request-method");
|
|
165
|
+
if (preflight) {
|
|
166
|
+
if (!allowed) return refusal(403, "origin_not_allowed", "This origin may not call the API.");
|
|
167
|
+
const headers = new Headers({
|
|
168
|
+
"access-control-allow-origin": origin,
|
|
169
|
+
"access-control-allow-methods": methods.join(", "),
|
|
170
|
+
"access-control-max-age": String(maxAge),
|
|
171
|
+
vary: "Origin, Access-Control-Request-Headers"
|
|
172
|
+
});
|
|
173
|
+
const asked = request.headers.get("access-control-request-headers");
|
|
174
|
+
const allowedHeaders = allowHeaders ? allowHeaders.join(", ") : asked;
|
|
175
|
+
if (allowedHeaders) headers.set("access-control-allow-headers", allowedHeaders);
|
|
176
|
+
if (credentials) headers.set("access-control-allow-credentials", "true");
|
|
177
|
+
return new Response(null, { status: 204, headers });
|
|
178
|
+
}
|
|
179
|
+
const response = await next();
|
|
180
|
+
return withHeaders(response, (headers) => {
|
|
181
|
+
appendVary(headers, "Origin");
|
|
182
|
+
if (!allowed) return;
|
|
183
|
+
headers.set("access-control-allow-origin", origin);
|
|
184
|
+
if (credentials) headers.set("access-control-allow-credentials", "true");
|
|
185
|
+
if (exposeHeaders?.length) headers.set("access-control-expose-headers", exposeHeaders.join(", "));
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
var SAFE = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
190
|
+
function originCheck(options) {
|
|
191
|
+
return (request, next) => {
|
|
192
|
+
if (SAFE.has(request.method) || !request.headers.has("cookie")) return next();
|
|
193
|
+
const origin = request.headers.get("origin");
|
|
194
|
+
const site = request.headers.get("sec-fetch-site");
|
|
195
|
+
const self = new URL(request.url).origin;
|
|
196
|
+
if (site === "same-origin" || site === "none") return next();
|
|
197
|
+
if (origin && (origin === self || allows(options.origins, origin))) return next();
|
|
198
|
+
if (!site && !origin) return next();
|
|
199
|
+
return refusal(403, "cross_origin_refused", "Requests from this origin are refused.");
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function apiSecurity(options = {}) {
|
|
203
|
+
const origins = options.origins ?? [];
|
|
204
|
+
const prefix = (options.prefix ?? "/api").replace(/\/$/, "");
|
|
205
|
+
const chain = [];
|
|
206
|
+
if (options.headers !== false) chain.push(securityHeaders(options.headers));
|
|
207
|
+
if (options.cors !== false) chain.push(cors({ ...options.cors, origins }));
|
|
208
|
+
if (options.originCheck !== false) chain.push(originCheck({ origins }));
|
|
209
|
+
for (const limit of options.rateLimits ?? []) chain.push(rateLimit(limit));
|
|
210
|
+
return (request, next) => {
|
|
211
|
+
const { pathname } = new URL(request.url);
|
|
212
|
+
if (pathname !== prefix && !pathname.startsWith(prefix + "/")) return next();
|
|
213
|
+
const run = (i) => {
|
|
214
|
+
const middleware = chain[i];
|
|
215
|
+
return middleware ? Promise.resolve(middleware(request, () => run(i + 1))) : next();
|
|
216
|
+
};
|
|
217
|
+
return run(0);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function refusal(status, code, message) {
|
|
221
|
+
return errorResponse(new ApiError(status, code, message));
|
|
222
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Middleware } from './middleware.js';
|
|
2
|
+
import { type RateLimitOptions } from './rate-limit.js';
|
|
3
|
+
/** Allowed origins: exact `scheme://host[:port]` strings, or a test. */
|
|
4
|
+
export type Origins = readonly string[] | ((origin: string) => boolean);
|
|
5
|
+
/** OWASP's recommendations for a JSON API: nothing in it is meant to render, frame or be cached. */
|
|
6
|
+
export declare const DEFAULT_SECURITY_HEADERS: Readonly<Record<string, string>>;
|
|
7
|
+
/**
|
|
8
|
+
* Header name to value, or `false` to leave that header out. Merged over the defaults. A header
|
|
9
|
+
* the response already has is kept: a route that sets its own `cache-control` wins.
|
|
10
|
+
*/
|
|
11
|
+
export type SecurityHeadersOptions = Record<string, string | false>;
|
|
12
|
+
export declare function securityHeaders(options?: SecurityHeadersOptions): Middleware;
|
|
13
|
+
export interface CorsOptions {
|
|
14
|
+
/** Who may call from another origin. Nobody when empty. */
|
|
15
|
+
origins: Origins;
|
|
16
|
+
/** Send cookies along. On: the session is a cookie. */
|
|
17
|
+
credentials?: boolean;
|
|
18
|
+
/** Allowed in a preflight. Every method a route can export by default. */
|
|
19
|
+
methods?: readonly string[];
|
|
20
|
+
/** Allowed request headers. What the preflight asks for when not given. */
|
|
21
|
+
allowHeaders?: readonly string[];
|
|
22
|
+
/** Response headers a script may read beyond the safelisted ones. */
|
|
23
|
+
exposeHeaders?: readonly string[];
|
|
24
|
+
/** How long a browser keeps a preflight answer, in seconds. */
|
|
25
|
+
maxAge?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* CORS for listed origins. A same-origin request, or one with no `Origin`, passes untouched.
|
|
29
|
+
* An unlisted origin's preflight is refused with a 403; its other requests go through without
|
|
30
|
+
* CORS headers, so the browser keeps the response from the page.
|
|
31
|
+
*/
|
|
32
|
+
export declare function cors(options: CorsOptions): Middleware;
|
|
33
|
+
/**
|
|
34
|
+
* Refuses a write that carries cookies from a page on another site, unless that origin is
|
|
35
|
+
* listed: cross-site request forgery against cookie sessions. Uses `Sec-Fetch-Site` where the
|
|
36
|
+
* browser sends it and `Origin` otherwise. A request with neither is not from a browser page
|
|
37
|
+
* (curl, another server), so there is no forgery to stop.
|
|
38
|
+
*/
|
|
39
|
+
export declare function originCheck(options: {
|
|
40
|
+
origins: Origins;
|
|
41
|
+
}): Middleware;
|
|
42
|
+
export interface ApiSecurityOptions {
|
|
43
|
+
/** Origins of the apps that call the API from a browser: CORS and the origin check. */
|
|
44
|
+
origins?: Origins;
|
|
45
|
+
/** Only requests under this path are touched. `/api`, start's API prefix. */
|
|
46
|
+
prefix?: string;
|
|
47
|
+
/** Overrides for the headers, or `false` for none. */
|
|
48
|
+
headers?: SecurityHeadersOptions | false;
|
|
49
|
+
/** CORS options beyond the origins, or `false` to answer no cross-origin call. */
|
|
50
|
+
cors?: Omit<CorsOptions, 'origins'> | false;
|
|
51
|
+
/** `false` turns the origin check off. */
|
|
52
|
+
originCheck?: false;
|
|
53
|
+
/** Limits, checked in order: sign-in and sign-up at least. */
|
|
54
|
+
rateLimits?: readonly RateLimitOptions[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The API's protections in one middleware: headers, CORS, the origin check, rate limits. Put it
|
|
58
|
+
* first in `src/middleware.ts` so refusals from what follows carry the headers too.
|
|
59
|
+
*
|
|
60
|
+
* With no options every cross-origin call is refused and nothing is rate limited: list the web
|
|
61
|
+
* app's origins, and limit the routes that guess passwords.
|
|
62
|
+
*/
|
|
63
|
+
export declare function apiSecurity(options?: ApiSecurityOptions): Middleware;
|
|
64
|
+
//# sourceMappingURL=security.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../src/security.ts"],"names":[],"mappings":"AASA,OAAO,EAA2B,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC3E,OAAO,EAAa,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEnE,wEAAwE;AACxE,MAAM,MAAM,OAAO,GAAG,SAAS,MAAM,EAAE,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC;AAOxE,oGAAoG;AACpG,eAAO,MAAM,wBAAwB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUrE,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC,CAAC;AAEpE,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,UAAU,CAOhF;AAID,MAAM,WAAW,WAAW;IAC1B,2DAA2D;IAC3D,OAAO,EAAE,OAAO,CAAC;IACjB,uDAAuD;IACvD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,2EAA2E;IAC3E,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,qEAAqE;IACrE,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,+DAA+D;IAC/D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAID;;;;GAIG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,UAAU,CAgCrD;AAMD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,UAAU,CAYrE;AAID,MAAM,WAAW,kBAAkB;IACjC,uFAAuF;IACvF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,OAAO,CAAC,EAAE,sBAAsB,GAAG,KAAK,CAAC;IACzC,kFAAkF;IAClF,IAAI,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,GAAG,KAAK,CAAC;IAC5C,0CAA0C;IAC1C,WAAW,CAAC,EAAE,KAAK,CAAC;IACpB,8DAA8D;IAC9D,UAAU,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CAC1C;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAkBxE"}
|
package/dist/security.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security middleware for API responses: headers, CORS and an origin check on cookie-carrying
|
|
3
|
+
* writes. Written against the published rules (OWASP Secure Headers Project, the Fetch standard)
|
|
4
|
+
* rather than taken from `helmet` or `cors`, which are built for Express's response object.
|
|
5
|
+
*
|
|
6
|
+
* Each works alone in `src/middleware.ts`; `apiSecurity()` puts them together with safe
|
|
7
|
+
* defaults, limited to the API's paths so pages keep their own policy.
|
|
8
|
+
*/
|
|
9
|
+
import { ApiError, errorResponse } from './errors.js';
|
|
10
|
+
import { appendVary, withHeaders } from './middleware.js';
|
|
11
|
+
import { rateLimit } from './rate-limit.js';
|
|
12
|
+
const allows = (origins, origin) => typeof origins === 'function' ? origins(origin) : origins.includes(origin);
|
|
13
|
+
// ─── Headers ────────────────────────────────────────────────────────────────
|
|
14
|
+
/** OWASP's recommendations for a JSON API: nothing in it is meant to render, frame or be cached. */
|
|
15
|
+
export const DEFAULT_SECURITY_HEADERS = {
|
|
16
|
+
'content-security-policy': "default-src 'none'; frame-ancestors 'none'",
|
|
17
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
18
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
19
|
+
'referrer-policy': 'no-referrer',
|
|
20
|
+
'strict-transport-security': 'max-age=31536000; includeSubDomains',
|
|
21
|
+
'x-content-type-options': 'nosniff',
|
|
22
|
+
'x-frame-options': 'DENY',
|
|
23
|
+
'x-permitted-cross-domain-policies': 'none',
|
|
24
|
+
'cache-control': 'no-store',
|
|
25
|
+
};
|
|
26
|
+
export function securityHeaders(options = {}) {
|
|
27
|
+
const merged = { ...DEFAULT_SECURITY_HEADERS, ...options };
|
|
28
|
+
const entries = Object.entries(merged).filter((e) => e[1] !== false);
|
|
29
|
+
return async (request, next) => withHeaders(await next(), (headers) => {
|
|
30
|
+
for (const [name, value] of entries)
|
|
31
|
+
if (!headers.has(name))
|
|
32
|
+
headers.set(name, value);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
36
|
+
/**
|
|
37
|
+
* CORS for listed origins. A same-origin request, or one with no `Origin`, passes untouched.
|
|
38
|
+
* An unlisted origin's preflight is refused with a 403; its other requests go through without
|
|
39
|
+
* CORS headers, so the browser keeps the response from the page.
|
|
40
|
+
*/
|
|
41
|
+
export function cors(options) {
|
|
42
|
+
const { origins, credentials = true, methods = METHODS, allowHeaders, exposeHeaders, maxAge = 600 } = options;
|
|
43
|
+
return async (request, next) => {
|
|
44
|
+
const origin = request.headers.get('origin');
|
|
45
|
+
if (!origin || origin === new URL(request.url).origin)
|
|
46
|
+
return next();
|
|
47
|
+
const allowed = allows(origins, origin);
|
|
48
|
+
const preflight = request.method === 'OPTIONS' && request.headers.has('access-control-request-method');
|
|
49
|
+
if (preflight) {
|
|
50
|
+
if (!allowed)
|
|
51
|
+
return refusal(403, 'origin_not_allowed', 'This origin may not call the API.');
|
|
52
|
+
const headers = new Headers({
|
|
53
|
+
'access-control-allow-origin': origin,
|
|
54
|
+
'access-control-allow-methods': methods.join(', '),
|
|
55
|
+
'access-control-max-age': String(maxAge),
|
|
56
|
+
vary: 'Origin, Access-Control-Request-Headers',
|
|
57
|
+
});
|
|
58
|
+
const asked = request.headers.get('access-control-request-headers');
|
|
59
|
+
const allowedHeaders = allowHeaders ? allowHeaders.join(', ') : asked;
|
|
60
|
+
if (allowedHeaders)
|
|
61
|
+
headers.set('access-control-allow-headers', allowedHeaders);
|
|
62
|
+
if (credentials)
|
|
63
|
+
headers.set('access-control-allow-credentials', 'true');
|
|
64
|
+
return new Response(null, { status: 204, headers });
|
|
65
|
+
}
|
|
66
|
+
const response = await next();
|
|
67
|
+
return withHeaders(response, (headers) => {
|
|
68
|
+
appendVary(headers, 'Origin');
|
|
69
|
+
if (!allowed)
|
|
70
|
+
return;
|
|
71
|
+
headers.set('access-control-allow-origin', origin);
|
|
72
|
+
if (credentials)
|
|
73
|
+
headers.set('access-control-allow-credentials', 'true');
|
|
74
|
+
if (exposeHeaders?.length)
|
|
75
|
+
headers.set('access-control-expose-headers', exposeHeaders.join(', '));
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// ─── Origin check ───────────────────────────────────────────────────────────
|
|
80
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
81
|
+
/**
|
|
82
|
+
* Refuses a write that carries cookies from a page on another site, unless that origin is
|
|
83
|
+
* listed: cross-site request forgery against cookie sessions. Uses `Sec-Fetch-Site` where the
|
|
84
|
+
* browser sends it and `Origin` otherwise. A request with neither is not from a browser page
|
|
85
|
+
* (curl, another server), so there is no forgery to stop.
|
|
86
|
+
*/
|
|
87
|
+
export function originCheck(options) {
|
|
88
|
+
return (request, next) => {
|
|
89
|
+
if (SAFE.has(request.method) || !request.headers.has('cookie'))
|
|
90
|
+
return next();
|
|
91
|
+
const origin = request.headers.get('origin');
|
|
92
|
+
const site = request.headers.get('sec-fetch-site');
|
|
93
|
+
const self = new URL(request.url).origin;
|
|
94
|
+
if (site === 'same-origin' || site === 'none')
|
|
95
|
+
return next();
|
|
96
|
+
if (origin && (origin === self || allows(options.origins, origin)))
|
|
97
|
+
return next();
|
|
98
|
+
if (!site && !origin)
|
|
99
|
+
return next();
|
|
100
|
+
return refusal(403, 'cross_origin_refused', 'Requests from this origin are refused.');
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The API's protections in one middleware: headers, CORS, the origin check, rate limits. Put it
|
|
105
|
+
* first in `src/middleware.ts` so refusals from what follows carry the headers too.
|
|
106
|
+
*
|
|
107
|
+
* With no options every cross-origin call is refused and nothing is rate limited: list the web
|
|
108
|
+
* app's origins, and limit the routes that guess passwords.
|
|
109
|
+
*/
|
|
110
|
+
export function apiSecurity(options = {}) {
|
|
111
|
+
const origins = options.origins ?? [];
|
|
112
|
+
const prefix = (options.prefix ?? '/api').replace(/\/$/, '');
|
|
113
|
+
const chain = [];
|
|
114
|
+
if (options.headers !== false)
|
|
115
|
+
chain.push(securityHeaders(options.headers));
|
|
116
|
+
if (options.cors !== false)
|
|
117
|
+
chain.push(cors({ ...options.cors, origins }));
|
|
118
|
+
if (options.originCheck !== false)
|
|
119
|
+
chain.push(originCheck({ origins }));
|
|
120
|
+
for (const limit of options.rateLimits ?? [])
|
|
121
|
+
chain.push(rateLimit(limit));
|
|
122
|
+
return (request, next) => {
|
|
123
|
+
const { pathname } = new URL(request.url);
|
|
124
|
+
if (pathname !== prefix && !pathname.startsWith(prefix + '/'))
|
|
125
|
+
return next();
|
|
126
|
+
const run = (i) => {
|
|
127
|
+
const middleware = chain[i];
|
|
128
|
+
return middleware ? Promise.resolve(middleware(request, () => run(i + 1))) : next();
|
|
129
|
+
};
|
|
130
|
+
return run(0);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function refusal(status, code, message) {
|
|
134
|
+
return errorResponse(new ApiError(status, code, message));
|
|
135
|
+
}
|