@webjsdev/server 0.8.9 → 0.8.11
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/index.js +6 -1
- package/package.json +1 -1
- package/src/actions.js +49 -7
- package/src/api.js +16 -1
- package/src/auth.js +25 -3
- package/src/body-limit.js +291 -0
- package/src/build-info.js +59 -0
- package/src/cache-fn.js +40 -0
- package/src/cache-tags.js +147 -0
- package/src/conditional-get.js +183 -0
- package/src/context.js +139 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +397 -31
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/html-cache.js +305 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/session.js +4 -0
- package/src/ssr.js +142 -24
- package/src/vendor.js +9 -6
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup env-var validation with a typed schema hook (issue #236).
|
|
3
|
+
*
|
|
4
|
+
* webjs auto-loads `<appDir>/.env` into `process.env` at boot, but does no
|
|
5
|
+
* validation, so a missing or misconfigured required var (DATABASE_URL,
|
|
6
|
+
* AUTH_SECRET, ...) fails late and cryptically (a Prisma connect error
|
|
7
|
+
* mid-request, an undefined secret signing a token). This module adds an
|
|
8
|
+
* optional boot-time validation hook that fails fast with one clear message
|
|
9
|
+
* listing EVERY missing or invalid var at once, before the app serves a
|
|
10
|
+
* request.
|
|
11
|
+
*
|
|
12
|
+
* The hook is an optional `env.{js,ts}` module at the app root (sibling of
|
|
13
|
+
* `middleware.js` / `readiness.js`), default-exporting either:
|
|
14
|
+
* 1. a plain SCHEMA object, dependency-free, e.g.
|
|
15
|
+
* export default {
|
|
16
|
+
* DATABASE_URL: 'string',
|
|
17
|
+
* AUTH_SECRET: { type: 'string', required: true, minLength: 16 },
|
|
18
|
+
* PORT: { type: 'number', optional: true, default: 3000 },
|
|
19
|
+
* NODE_ENV: { type: 'enum', values: ['development','production','test'] },
|
|
20
|
+
* };
|
|
21
|
+
* 2. a FUNCTION `(env) => void | throw` for full custom validation (the
|
|
22
|
+
* escape hatch), so an app can use zod or anything it likes without webjs
|
|
23
|
+
* depending on it. A thrown Error is surfaced as the boot failure.
|
|
24
|
+
*
|
|
25
|
+
* The validator is a PURE function (`validateEnv`) so it unit-tests with an
|
|
26
|
+
* injected schema + env object, no temp app dir needed. `loadEnvSchema` reads
|
|
27
|
+
* the app's `env.{js,ts}` (returns `null` when absent, so the feature is fully
|
|
28
|
+
* opt-in), and `applyEnvValidation` is the side-effecting boot wrapper: it runs
|
|
29
|
+
* the schema/function against `process.env`, applies coerced + defaulted values
|
|
30
|
+
* back into `process.env`, and throws a clear aggregated Error on failure (so
|
|
31
|
+
* `createRequestHandler` rejects and the CLI exits non-zero, consistent with
|
|
32
|
+
* the Node-version preflight).
|
|
33
|
+
*/
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { pathToFileURL } from 'node:url';
|
|
36
|
+
import { stat } from 'node:fs/promises';
|
|
37
|
+
|
|
38
|
+
/** Field type names a schema may declare. */
|
|
39
|
+
const KNOWN_TYPES = new Set(['string', 'number', 'boolean', 'url', 'enum']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a schema field to its object form. A bare string like `'string'`
|
|
43
|
+
* is shorthand for `{ type: 'string' }`.
|
|
44
|
+
* @param {string | object} rule
|
|
45
|
+
* @returns {{ type: string, required?: boolean, optional?: boolean, default?: any, values?: any[], minLength?: number, pattern?: (string|RegExp) }}
|
|
46
|
+
*/
|
|
47
|
+
function normalizeRule(rule) {
|
|
48
|
+
if (typeof rule === 'string') return { type: rule };
|
|
49
|
+
return rule && typeof rule === 'object' ? rule : { type: 'string' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Is a field required? A field is required by default. It is optional when it
|
|
54
|
+
* declares `optional: true`, `required: false`, or carries a `default`.
|
|
55
|
+
* @param {object} rule normalized rule
|
|
56
|
+
*/
|
|
57
|
+
function isRequired(rule) {
|
|
58
|
+
if (rule.required === false) return false;
|
|
59
|
+
if (rule.optional === true) return false;
|
|
60
|
+
if ('default' in rule) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Coerce a raw string value to the declared type, enforcing the field's
|
|
66
|
+
* constraints. Returns `{ value }` on success or `{ error }` (a human string)
|
|
67
|
+
* on failure.
|
|
68
|
+
* @param {string} name the env var name (for messages)
|
|
69
|
+
* @param {object} rule normalized rule
|
|
70
|
+
* @param {string} raw the raw string from the env
|
|
71
|
+
* @returns {{ value: any } | { error: string }}
|
|
72
|
+
*/
|
|
73
|
+
function coerce(name, rule, raw) {
|
|
74
|
+
const type = rule.type || 'string';
|
|
75
|
+
switch (type) {
|
|
76
|
+
case 'string': {
|
|
77
|
+
if (typeof rule.minLength === 'number' && raw.length < rule.minLength) {
|
|
78
|
+
return { error: `${name} must be at least ${rule.minLength} characters (got ${raw.length})` };
|
|
79
|
+
}
|
|
80
|
+
if (rule.pattern != null) {
|
|
81
|
+
const re = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern);
|
|
82
|
+
if (!re.test(raw)) return { error: `${name} does not match required pattern ${re}` };
|
|
83
|
+
}
|
|
84
|
+
return { value: raw };
|
|
85
|
+
}
|
|
86
|
+
case 'number': {
|
|
87
|
+
const n = Number(raw);
|
|
88
|
+
if (raw.trim() === '' || Number.isNaN(n)) {
|
|
89
|
+
// Never echo the value: a secret given the wrong type would leak to logs.
|
|
90
|
+
return { error: `${name} must be a number` };
|
|
91
|
+
}
|
|
92
|
+
return { value: n };
|
|
93
|
+
}
|
|
94
|
+
case 'boolean': {
|
|
95
|
+
const v = raw.trim().toLowerCase();
|
|
96
|
+
if (['1', 'true', 'yes', 'on'].includes(v)) return { value: true };
|
|
97
|
+
if (['0', 'false', 'no', 'off'].includes(v)) return { value: false };
|
|
98
|
+
// Never echo the value: list the accepted spellings, not what was given.
|
|
99
|
+
return { error: `${name} must be a boolean (one of true/false/1/0/yes/no/on/off)` };
|
|
100
|
+
}
|
|
101
|
+
case 'url': {
|
|
102
|
+
try {
|
|
103
|
+
// eslint-disable-next-line no-new
|
|
104
|
+
new URL(raw);
|
|
105
|
+
return { value: raw };
|
|
106
|
+
} catch {
|
|
107
|
+
// Never echo the value: a DSN with embedded credentials must not leak.
|
|
108
|
+
return { error: `${name} must be a valid URL` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
case 'enum': {
|
|
112
|
+
const values = Array.isArray(rule.values) ? rule.values : [];
|
|
113
|
+
if (!values.includes(raw)) {
|
|
114
|
+
// Name the ALLOWED values (from the schema, safe), never the provided one.
|
|
115
|
+
return { error: `${name} must be one of ${values.map((v) => JSON.stringify(v)).join(', ')}` };
|
|
116
|
+
}
|
|
117
|
+
return { value: raw };
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
return { error: `${name} has an unknown schema type ${JSON.stringify(type)}` };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pure env validator. Validates `env` against `schema`, collecting ALL errors
|
|
126
|
+
* (never stopping at the first), and computes the coerced + defaulted values to
|
|
127
|
+
* write back. Does NOT mutate `env` or `process.env`; the caller applies
|
|
128
|
+
* `coerced` to `process.env`.
|
|
129
|
+
*
|
|
130
|
+
* When `schema` is a FUNCTION it is the custom-validator escape hatch: it is
|
|
131
|
+
* called with the env object and any thrown Error is surfaced as a single
|
|
132
|
+
* error. A function validator never coerces.
|
|
133
|
+
*
|
|
134
|
+
* @param {object | Function} schema the default export of `env.{js,ts}`
|
|
135
|
+
* @param {Record<string, string|undefined>} env the source env (e.g. process.env)
|
|
136
|
+
* @returns {{ ok: boolean, errors: string[], coerced: Record<string, string> }}
|
|
137
|
+
*/
|
|
138
|
+
export function validateEnv(schema, env) {
|
|
139
|
+
// Escape hatch: a function gets the env and validates however it wants.
|
|
140
|
+
if (typeof schema === 'function') {
|
|
141
|
+
try {
|
|
142
|
+
schema(env);
|
|
143
|
+
return { ok: true, errors: [], coerced: {} };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
const msg = e && e.message ? String(e.message) : String(e);
|
|
146
|
+
return { ok: false, errors: [msg], coerced: {} };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!schema || typeof schema !== 'object') {
|
|
151
|
+
// Nothing to validate against. Treat as a no-op rather than an error so a
|
|
152
|
+
// stray default export does not brick boot.
|
|
153
|
+
return { ok: true, errors: [], coerced: {} };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const errors = [];
|
|
157
|
+
/** @type {Record<string, string>} */
|
|
158
|
+
const coerced = {};
|
|
159
|
+
|
|
160
|
+
for (const name of Object.keys(schema)) {
|
|
161
|
+
const rule = normalizeRule(schema[name]);
|
|
162
|
+
const type = rule.type || 'string';
|
|
163
|
+
if (!KNOWN_TYPES.has(type)) {
|
|
164
|
+
errors.push(`${name} declares an unknown type ${JSON.stringify(type)} (expected one of ${[...KNOWN_TYPES].join(', ')})`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const present = env[name] != null && env[name] !== '';
|
|
168
|
+
if (!present) {
|
|
169
|
+
if (isRequired(rule)) {
|
|
170
|
+
errors.push(`${name} is required but missing`);
|
|
171
|
+
} else if ('default' in rule) {
|
|
172
|
+
// Apply the default, coercing it through the same path so a number
|
|
173
|
+
// default lands as a string in process.env (env values are strings).
|
|
174
|
+
coerced[name] = String(rule.default);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const result = coerce(name, rule, String(env[name]));
|
|
179
|
+
if ('error' in result) {
|
|
180
|
+
errors.push(result.error);
|
|
181
|
+
} else if (typeof result.value !== 'string') {
|
|
182
|
+
// Re-stringify coerced non-string values so process.env stays string-typed.
|
|
183
|
+
coerced[name] = String(result.value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { ok: errors.length === 0, errors, coerced };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Compose the aggregated, actionable failure message from a list of errors.
|
|
192
|
+
* @param {string[]} errors
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function formatEnvErrors(errors) {
|
|
196
|
+
const lines = errors.map((e) => ` - ${e}`);
|
|
197
|
+
return (
|
|
198
|
+
`webjs env validation failed (${errors.length} ${errors.length === 1 ? 'error' : 'errors'}):\n` +
|
|
199
|
+
lines.join('\n') +
|
|
200
|
+
`\n\nFix the variables above in your .env (or the host environment) and restart. ` +
|
|
201
|
+
`The schema lives in env.{js,ts} at the app root.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Load the optional `env.{js,ts}` schema module from the app root. Returns the
|
|
207
|
+
* default export (a schema object or a validator function), or `null` when no
|
|
208
|
+
* such file exists, so env validation is fully opt-in.
|
|
209
|
+
* @param {string} appDir
|
|
210
|
+
* @param {{ dev?: boolean }} [opts]
|
|
211
|
+
* @returns {Promise<object | Function | null>}
|
|
212
|
+
*/
|
|
213
|
+
export async function loadEnvSchema(appDir, opts = {}) {
|
|
214
|
+
let file = null;
|
|
215
|
+
for (const name of ['env.ts', 'env.js', 'env.mts', 'env.mjs']) {
|
|
216
|
+
const p = join(appDir, name);
|
|
217
|
+
try {
|
|
218
|
+
await stat(p);
|
|
219
|
+
file = p;
|
|
220
|
+
break;
|
|
221
|
+
} catch {
|
|
222
|
+
// not this name, try the next
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!file) return null;
|
|
226
|
+
const url = pathToFileURL(file).toString();
|
|
227
|
+
const bust = opts.dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
|
|
228
|
+
const mod = await import(url + bust);
|
|
229
|
+
return mod.default ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Side-effecting boot wrapper: load the app's `env.{js,ts}` (if any), validate
|
|
234
|
+
* `process.env` against it, apply coerced + defaulted values back into
|
|
235
|
+
* `process.env`, and THROW a clear aggregated Error on failure. A no-op when
|
|
236
|
+
* `env.{js,ts}` is absent. Called by `createRequestHandler` right after the
|
|
237
|
+
* `.env` auto-load and before any server-only module is imported.
|
|
238
|
+
* @param {string} appDir
|
|
239
|
+
* @param {{ dev?: boolean, env?: Record<string, string|undefined> }} [opts]
|
|
240
|
+
* @returns {Promise<void>}
|
|
241
|
+
*/
|
|
242
|
+
export async function applyEnvValidation(appDir, opts = {}) {
|
|
243
|
+
const schema = await loadEnvSchema(appDir, opts);
|
|
244
|
+
if (schema == null) return; // opt-in: no env.{js,ts}, nothing to do
|
|
245
|
+
const env = opts.env ?? process.env;
|
|
246
|
+
const { ok, errors, coerced } = validateEnv(schema, env);
|
|
247
|
+
if (!ok) throw new Error(formatEnvErrors(errors));
|
|
248
|
+
// Apply coerced values + defaults back so the app reads the coerced value.
|
|
249
|
+
for (const key of Object.keys(coerced)) env[key] = coerced[key];
|
|
250
|
+
}
|
package/src/headers.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure-by-default response headers plus a small declarative per-path
|
|
3
|
+
* header config (issue #232).
|
|
4
|
+
*
|
|
5
|
+
* webjs sets a baseline of standard security headers on every document
|
|
6
|
+
* and asset response, so a scaffolded app is not clickjackable or
|
|
7
|
+
* MIME-sniffable out of the box (no reverse proxy required for the
|
|
8
|
+
* baseline). The defaults are LITERAL HTTP headers, no abstraction.
|
|
9
|
+
*
|
|
10
|
+
* An app can ADD, OVERRIDE, or DISABLE any header per path via a
|
|
11
|
+
* declarative config (`package.json` -> `webjs.headers`), shaped like
|
|
12
|
+
* Next's: an array of `{ source, headers: [{ key, value }] }` where
|
|
13
|
+
* `source` is a path pattern matched with the native URLPattern API.
|
|
14
|
+
*
|
|
15
|
+
* Precedence, lowest to highest:
|
|
16
|
+
* 1. secure defaults
|
|
17
|
+
* 2. path config (webjs.headers) overrides/adds/removes a default
|
|
18
|
+
* 3. app middleware (already on the Response when we merge)
|
|
19
|
+
*
|
|
20
|
+
* In other words middleware wins over the path config, which wins over
|
|
21
|
+
* the defaults. We only ever ADD a header the response does not already
|
|
22
|
+
* carry, so a header an app set (in middleware, a route handler, or via
|
|
23
|
+
* `expose`) is never clobbered. A path-config entry with a null/empty
|
|
24
|
+
* value REMOVES a header (e.g. drop a default on a public embed route).
|
|
25
|
+
*
|
|
26
|
+
* This is the connective tissue #233 (CSP) and #234 (CORS) plug into
|
|
27
|
+
* later: the merge seam (applySecurityHeaders) is the single place a
|
|
28
|
+
* future per-path policy layers onto a Response, so neither needs to
|
|
29
|
+
* touch the response pipeline again.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Baseline security headers, as literal name -> value pairs. Set only
|
|
34
|
+
* when absent, so an app override always wins. HSTS is NOT here: it is
|
|
35
|
+
* conditional (production + HTTPS) and added separately.
|
|
36
|
+
*
|
|
37
|
+
* @type {ReadonlyArray<[string, string]>}
|
|
38
|
+
*/
|
|
39
|
+
const SECURE_DEFAULTS = [
|
|
40
|
+
['X-Content-Type-Options', 'nosniff'],
|
|
41
|
+
['X-Frame-Options', 'SAMEORIGIN'],
|
|
42
|
+
['Referrer-Policy', 'strict-origin-when-cross-origin'],
|
|
43
|
+
['Permissions-Policy', 'camera=(), microphone=(), geolocation=()'],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** The standard HSTS posture: two years, include subdomains. */
|
|
47
|
+
const HSTS_VALUE = 'max-age=63072000; includeSubDomains';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the per-path header config from the app's package.json
|
|
51
|
+
* (`webjs.headers`). Returns a normalized array of compiled rules, each
|
|
52
|
+
* pairing a URLPattern against the configured header directives. A
|
|
53
|
+
* malformed or absent config yields an empty array (no per-path rules),
|
|
54
|
+
* never a throw: a broken config must not take the app down.
|
|
55
|
+
*
|
|
56
|
+
* Shape consumed:
|
|
57
|
+
* "webjs": { "headers": [ { "source": "/embed/:path*",
|
|
58
|
+
* "headers": [ { "key": "X-Frame-Options", "value": null } ] } ] }
|
|
59
|
+
*
|
|
60
|
+
* A `value` of null, undefined, or false REMOVES the header on a match
|
|
61
|
+
* (the disable-a-default escape hatch). Any other value is stringified
|
|
62
|
+
* and SET (override or add).
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
65
|
+
* @returns {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>}
|
|
66
|
+
*/
|
|
67
|
+
export function compileHeaderRules(pkg) {
|
|
68
|
+
const raw =
|
|
69
|
+
pkg &&
|
|
70
|
+
typeof pkg === 'object' &&
|
|
71
|
+
/** @type {any} */ (pkg).webjs &&
|
|
72
|
+
/** @type {any} */ (pkg).webjs.headers;
|
|
73
|
+
if (!Array.isArray(raw)) return [];
|
|
74
|
+
/** @type {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>} */
|
|
75
|
+
const rules = [];
|
|
76
|
+
for (const entry of raw) {
|
|
77
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
78
|
+
const source = /** @type {any} */ (entry).source;
|
|
79
|
+
const list = /** @type {any} */ (entry).headers;
|
|
80
|
+
if (typeof source !== 'string' || !Array.isArray(list)) continue;
|
|
81
|
+
let pattern;
|
|
82
|
+
try {
|
|
83
|
+
// Match on the pathname only. A bare path string is the common
|
|
84
|
+
// Next-style usage; URLPattern treats it as the pathname component.
|
|
85
|
+
pattern = new URLPattern({ pathname: source });
|
|
86
|
+
} catch {
|
|
87
|
+
continue; // skip an invalid pattern rather than crash the request
|
|
88
|
+
}
|
|
89
|
+
/** @type {Array<{ key: string, value: string | null }>} */
|
|
90
|
+
const directives = [];
|
|
91
|
+
for (const d of list) {
|
|
92
|
+
if (!d || typeof d !== 'object') continue;
|
|
93
|
+
const key = /** @type {any} */ (d).key;
|
|
94
|
+
if (typeof key !== 'string' || !key) continue;
|
|
95
|
+
const v = /** @type {any} */ (d).value;
|
|
96
|
+
// null / undefined / false means REMOVE the header on a match.
|
|
97
|
+
const value = v === null || v === undefined || v === false ? null : String(v);
|
|
98
|
+
// Validate the key/value against the real Headers parser at COMPILE
|
|
99
|
+
// time (consistent with dropping a bad `source`). A name or value
|
|
100
|
+
// that Headers rejects (an invalid header name, or a value carrying
|
|
101
|
+
// CR/LF) would otherwise make `applySecurityHeaders` THROW on every
|
|
102
|
+
// matching request, a self-inflicted 500, which breaks this file's
|
|
103
|
+
// "a broken config must not take the app down" guarantee. Probe on a
|
|
104
|
+
// throwaway Headers and DROP the directive if it throws. For a delete
|
|
105
|
+
// (value null) only the key needs to be a valid header name, so probe
|
|
106
|
+
// it with a placeholder value.
|
|
107
|
+
try {
|
|
108
|
+
new Headers().set(key, value === null ? 'x' : value);
|
|
109
|
+
} catch {
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.warn(`[webjs] dropping invalid webjs.headers directive for key "${key}"`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
directives.push({ key, value });
|
|
115
|
+
}
|
|
116
|
+
if (directives.length) rules.push({ pattern, directives });
|
|
117
|
+
}
|
|
118
|
+
return rules;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Apply the security defaults and the per-path config to a Response,
|
|
123
|
+
* returning a Response carrying the merged headers. The input Response
|
|
124
|
+
* is not mutated; a new Headers is derived from it so the body/status
|
|
125
|
+
* are preserved by reference (no body copy).
|
|
126
|
+
*
|
|
127
|
+
* Headers already on the response (set by app middleware, a route
|
|
128
|
+
* handler, or `expose`) are treated as authoritative and never
|
|
129
|
+
* overwritten by a default. The per-path config runs AFTER the defaults
|
|
130
|
+
* but BEFORE that "already present" rule is consulted for middleware:
|
|
131
|
+
* the config can override a default freely (it is the app's own
|
|
132
|
+
* declarative intent), while middleware still wins because its headers
|
|
133
|
+
* are on the response before we are even called.
|
|
134
|
+
*
|
|
135
|
+
* @param {Response} res the response produced by the app pipeline
|
|
136
|
+
* @param {{
|
|
137
|
+
* pathname: string,
|
|
138
|
+
* https: boolean,
|
|
139
|
+
* prod: boolean,
|
|
140
|
+
* rules?: Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>,
|
|
141
|
+
* }} ctx
|
|
142
|
+
* @returns {Response}
|
|
143
|
+
*/
|
|
144
|
+
export function applySecurityHeaders(res, ctx) {
|
|
145
|
+
const headers = new Headers(res.headers);
|
|
146
|
+
// Snapshot the keys the app already set, so a default never clobbers
|
|
147
|
+
// them. Captured BEFORE we add anything. Lowercased for compare;
|
|
148
|
+
// Headers itself is case-insensitive.
|
|
149
|
+
const appSet = new Set();
|
|
150
|
+
headers.forEach((_v, k) => appSet.add(k.toLowerCase()));
|
|
151
|
+
|
|
152
|
+
// 1. Secure defaults: set only if the app did not already set them.
|
|
153
|
+
for (const [k, v] of SECURE_DEFAULTS) {
|
|
154
|
+
if (!appSet.has(k.toLowerCase())) headers.set(k, v);
|
|
155
|
+
}
|
|
156
|
+
// HSTS only in production AND only over HTTPS (never on a plain-HTTP
|
|
157
|
+
// hop). Same "do not clobber" rule.
|
|
158
|
+
if (ctx.prod && ctx.https && !appSet.has('strict-transport-security')) {
|
|
159
|
+
headers.set('Strict-Transport-Security', HSTS_VALUE);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2. Per-path config: override / add / remove. Runs over the merged
|
|
163
|
+
// set so it can replace a default this same call just added. It does
|
|
164
|
+
// NOT override a header the app set in middleware (appSet), preserving
|
|
165
|
+
// the middleware-wins precedence.
|
|
166
|
+
const rules = ctx.rules || [];
|
|
167
|
+
for (const rule of rules) {
|
|
168
|
+
if (!rule.pattern.test({ pathname: ctx.pathname })) continue;
|
|
169
|
+
for (const { key, value } of rule.directives) {
|
|
170
|
+
if (appSet.has(key.toLowerCase())) continue; // middleware wins
|
|
171
|
+
// Belt and suspenders: compileHeaderRules already validated the
|
|
172
|
+
// key/value, so this never throws in practice, but a surprise from a
|
|
173
|
+
// directive constructed elsewhere must never throw the response
|
|
174
|
+
// pipeline. Skip the bad directive instead.
|
|
175
|
+
try {
|
|
176
|
+
if (value === null) headers.delete(key);
|
|
177
|
+
else headers.set(key, value);
|
|
178
|
+
} catch {
|
|
179
|
+
/* skip a directive the Headers parser rejects */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return new Response(res.body, {
|
|
185
|
+
status: res.status,
|
|
186
|
+
statusText: res.statusText,
|
|
187
|
+
headers,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Detect whether the original client request was HTTPS, from a web
|
|
193
|
+
* `Request` (the shape `handle()` works with). Honors the same
|
|
194
|
+
* reverse-proxy trust posture as `forwarded.js`: read
|
|
195
|
+
* `X-Forwarded-Proto` only when proxy trust is on
|
|
196
|
+
* (`WEBJS_NO_TRUST_PROXY !== '1'`); otherwise fall back to the request
|
|
197
|
+
* URL's scheme. Never trusts the header blindly.
|
|
198
|
+
*
|
|
199
|
+
* @param {Request} req
|
|
200
|
+
* @returns {boolean}
|
|
201
|
+
*/
|
|
202
|
+
export function webRequestIsHttps(req) {
|
|
203
|
+
const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
|
|
204
|
+
if (trust) {
|
|
205
|
+
const proto = req.headers.get('x-forwarded-proto');
|
|
206
|
+
if (proto) return proto.split(',')[0].trim().toLowerCase() === 'https';
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return new URL(req.url).protocol === 'https:';
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|