@fuzdev/fuz_app 0.144.0 → 0.146.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/dist/actions/CLAUDE.md +7 -6
- package/dist/auth/CLAUDE.md +5 -2
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +2 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +9 -2
- package/dist/auth/account_routes.d.ts +8 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +12 -3
- package/dist/auth/request_context.d.ts +23 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +32 -1
- package/dist/auth/token_scope.d.ts +22 -14
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +23 -14
- package/dist/db/CLAUDE.md +7 -3
- package/dist/db/fact_ddl.d.ts +1 -0
- package/dist/db/fact_ddl.d.ts.map +1 -1
- package/dist/db/fact_ddl.js +1 -0
- package/dist/db/fact_holder_queries.d.ts +8 -4
- package/dist/db/fact_holder_queries.d.ts.map +1 -1
- package/dist/db/fact_holder_queries.js +9 -5
- package/dist/db/fact_queries.d.ts +10 -7
- package/dist/db/fact_queries.d.ts.map +1 -1
- package/dist/db/fact_queries.js +31 -10
- package/dist/http/CLAUDE.md +2 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +11 -6
- package/dist/http/proxy.d.ts +22 -21
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +28 -33
- package/dist/http/route_spec.d.ts +8 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +1 -1
- package/dist/server/fact_security_headers.d.ts +59 -0
- package/dist/server/fact_security_headers.d.ts.map +1 -0
- package/dist/server/fact_security_headers.js +58 -0
- package/dist/server/serve_fact_route.d.ts +21 -10
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +41 -50
- package/dist/server/x_accel.d.ts +135 -31
- package/dist/server/x_accel.d.ts.map +1 -1
- package/dist/server/x_accel.js +652 -88
- package/dist/testing/CLAUDE.md +73 -22
- package/dist/testing/cross_backend/capabilities.d.ts +0 -18
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -4
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +0 -7
- package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
- package/dist/testing/cross_backend/fact_serving.js +85 -12
- package/dist/testing/cross_backend/identity_parity.d.ts +11 -0
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
- package/dist/testing/cross_backend/identity_parity.js +124 -9
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +28 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts +70 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -0
- package/dist/testing/cross_backend/revocation_drivers.js +104 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts +6 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +52 -16
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_scope_surface.js +33 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +13 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +155 -39
- package/package.json +3 -3
package/dist/server/x_accel.js
CHANGED
|
@@ -5,104 +5,278 @@
|
|
|
5
5
|
* nginx location being `internal;` — only the authz'd handler's redirect (an
|
|
6
6
|
* internal subrequest) may reach it. A *public* facts location would serve any
|
|
7
7
|
* fact's bytes to anyone who guesses the `<shard>/<rest>` path, bypassing every
|
|
8
|
-
* cell-visibility check. `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* cell-visibility check. A location with its own `add_header` also stops the
|
|
9
|
+
* server-level headers from applying, so the facts location must carry the fact
|
|
10
|
+
* hardening headers itself (`fact_security_header_directives` in
|
|
11
|
+
* `server/fact_security_headers.ts`).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `XAccelConfig` makes these assertions structural: a consumer can only obtain
|
|
14
|
+
* the redirect prefix by building an `XAccelConfig`, which runs
|
|
15
|
+
* `validate_facts_internal_location` and throws **loudly** on any violation — it
|
|
16
|
+
* cannot silently fall closed.
|
|
17
|
+
*
|
|
18
|
+
* ## Rules
|
|
19
|
+
*
|
|
20
|
+
* The config is tokenized as nginx reads it (`ngx_conf_read_token`): whitespace
|
|
21
|
+
* is exactly space, tab, CR and LF; an unquoted word ends only at those or at
|
|
22
|
+
* `;` / `{` (a `}` inside a word is part of it, and so is a `{` straight after
|
|
23
|
+
* `$`); `#` starts a comment only where a token would start; quoted strings are
|
|
24
|
+
* one word, with nginx's backslash escapes. It is then parsed into nested
|
|
25
|
+
* blocks, and every `location` at any depth, in every `server` block, is
|
|
26
|
+
* classified against the prefix (trailing slashes ignored). A location *serves
|
|
27
|
+
* the prefix* when it is a prefix-type location (no modifier, or `^~`) whose
|
|
28
|
+
* path is the prefix or below it. Each one must:
|
|
29
|
+
*
|
|
30
|
+
* - use `^~` — a plain prefix location is refused;
|
|
31
|
+
* - be a direct child of a `server` block — one nested in another location is
|
|
32
|
+
* refused;
|
|
33
|
+
* - carry, as top-level directives of its own body, an exact `internal;` and
|
|
34
|
+
* each `fact_security_header_directives` line (token-equal: whitespace and
|
|
35
|
+
* quoting are free, the words are not);
|
|
36
|
+
* - set those headers with no other `add_header` — one naming a
|
|
37
|
+
* `FACT_SECURITY_HEADERS` header (ASCII case-insensitive) that is not
|
|
38
|
+
* token-equal to its required line is refused, since nginx sends both values
|
|
39
|
+
* and a client may read only the first (`sniff, nosniff`);
|
|
40
|
+
* - hold only simple directives — any nested block (`location`, `if`,
|
|
41
|
+
* `limit_except`, …) is refused, and so is `include`, since included files
|
|
42
|
+
* are not followed.
|
|
43
|
+
*
|
|
44
|
+
* The first two rules are what keep regex locations out: nginx never evaluates
|
|
45
|
+
* regex locations for a path whose longest prefix match is a `^~` location at
|
|
46
|
+
* server level, and because every prefix location at or below the prefix must
|
|
47
|
+
* be such a location, the longest match for any redirected path always is. (A
|
|
48
|
+
* longer plain location below the prefix can't exist — the `^~` rule refuses
|
|
49
|
+
* it.)
|
|
50
|
+
*
|
|
51
|
+
* The prefix may appear only in its own location lines and their bodies:
|
|
52
|
+
* `internal;` admits every internal redirect and subrequest, so any other
|
|
53
|
+
* directive with a word naming the prefix (trailing slash trimmed, so
|
|
54
|
+
* `/_facts$uri` counts) is refused, in any block at any depth — a `rewrite`,
|
|
55
|
+
* `try_files`, `error_page`, `index`, `add_before_body`, or a `set` / `map`
|
|
56
|
+
* value that a redirect later reads. The only exemptions are each `location`'s
|
|
57
|
+
* own modifier and path words and the bodies of the locations serving the
|
|
58
|
+
* prefix.
|
|
59
|
+
*
|
|
60
|
+
* The prefix itself must be an absolute path ending in exactly one `/` — the
|
|
61
|
+
* server appends `<shard>/<rest>` straight onto it, so `/_facts` would redirect
|
|
62
|
+
* to `/_factsab/…`, outside the validated location. At least one location
|
|
63
|
+
* serving the prefix must sit exactly at it (`/_facts` or `/_facts/`): one only
|
|
64
|
+
* below it (`location ^~ /_facts/00/`) leaves every other redirect path to
|
|
65
|
+
* whatever location matches next, and nginx compares location paths
|
|
66
|
+
* byte-for-byte, so `/_facts//` sits below it too.
|
|
67
|
+
*
|
|
68
|
+
* Module directives that undo those guarantees are refused too:
|
|
69
|
+
*
|
|
70
|
+
* - `ssi` with any value but `off`, anywhere in the config. SSI `include
|
|
71
|
+
* virtual` is a subrequest, which `internal;` admits, so an SSI-processed
|
|
72
|
+
* response — a fact inheriting `ssi on` from `http` or `server`, or any page
|
|
73
|
+
* carrying user content — could include another fact's bytes.
|
|
74
|
+
* - `more_set_headers` / `more_clear_headers` (headers-more) everywhere except
|
|
75
|
+
* inside a `location` that doesn't serve the prefix (or a block within one):
|
|
76
|
+
* they run after `add_header` and can strip the hardening headers, so they
|
|
77
|
+
* are refused in the facts location and at every level above it, including
|
|
78
|
+
* any block there (`if`, another `server`).
|
|
79
|
+
*
|
|
80
|
+
* Also refused: an `=` location on the prefix or below it (it can't serve the
|
|
81
|
+
* nested paths the redirect emits); a regex location (`~` / `~*`) whose pattern
|
|
82
|
+
* spells out the prefix (after unescaping `\/`, ASCII case-folded for `~*`) —
|
|
83
|
+
* dead under the rules above, refused for the clearer message; a config with no
|
|
84
|
+
* location at the prefix; and blocks nested more than 64 deep (a parse error, so
|
|
85
|
+
* the walks can't exhaust the stack). `include` at server level is allowed, but
|
|
86
|
+
* its file is not read — keep facts-related locations and the directives above
|
|
87
|
+
* out of included files.
|
|
88
|
+
*
|
|
89
|
+
* The threat model is operator mistakes, not an adversarial config author: the
|
|
90
|
+
* prefix check is textual, so a value assembled from pieces (`/_fac$1`) evades
|
|
91
|
+
* it. Beyond the config text:
|
|
92
|
+
*
|
|
93
|
+
* - The validator checks URL paths only — another `root` or `alias` that maps
|
|
94
|
+
* onto the facts directory would serve its files publicly, so keep the facts
|
|
95
|
+
* directory outside every other location's root.
|
|
96
|
+
* - nginx honours `X-Accel-Redirect` from any proxied upstream (`proxy_pass`,
|
|
97
|
+
* `fastcgi_pass`, `uwsgi_pass`, `scgi_pass`, `grpc_pass`), so every upstream in
|
|
98
|
+
* the server is trusted to point into the prefix; an untrusted one needs
|
|
99
|
+
* `proxy_ignore_headers X-Accel-Redirect;` (or the `fastcgi_` / `uwsgi_` /
|
|
100
|
+
* `scgi_` equivalent).
|
|
101
|
+
* - Other module code can redirect internally too (njs `internalRedirect`, perl,
|
|
102
|
+
* echo), or strip the hardening headers as headers-more can (njs
|
|
103
|
+
* `js_header_filter`, lua-nginx-module `header_filter_by_lua*`), and the
|
|
104
|
+
* check sees none of it, so run trusted modules and content only.
|
|
105
|
+
*
|
|
106
|
+
* The Rust twin is `fuz_fact_serving`'s `nginx.rs`, held to the same rules and
|
|
107
|
+
* the same fixture corpus.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* const x_accel = create_x_accel_config(
|
|
112
|
+
* '/_facts/',
|
|
113
|
+
* `server {
|
|
114
|
+
* location ^~ /_facts/ {
|
|
115
|
+
* internal;
|
|
116
|
+
* alias /srv/app/facts/;
|
|
117
|
+
* ${fact_security_header_directives().join('\n\t\t')}
|
|
118
|
+
* }
|
|
119
|
+
* }`
|
|
120
|
+
* );
|
|
121
|
+
* ```
|
|
17
122
|
*
|
|
18
123
|
* @module
|
|
19
124
|
*/
|
|
20
125
|
import { z } from 'zod';
|
|
21
|
-
|
|
22
|
-
const trim_trailing_slashes = (value) => value.replace(/\/+$/, '');
|
|
23
|
-
/** `true` if a location block body contains an `internal` directive. */
|
|
24
|
-
const is_internal = (content) => content.split(/[;\s]+/).some((token) => token === 'internal');
|
|
25
|
-
/**
|
|
26
|
-
* Extract `{path, content}` for each `location … { … }` block, brace-matched so
|
|
27
|
-
* nested blocks are kept whole. `path` is the last whitespace token before the
|
|
28
|
-
* opening brace (the location path, after any modifier). Best-effort: a stray
|
|
29
|
-
* `location` token in a comment can produce a spurious block, harmless for this
|
|
30
|
-
* check.
|
|
31
|
-
*/
|
|
32
|
-
const find_location_blocks = (config) => {
|
|
33
|
-
const blocks = [];
|
|
34
|
-
let search = 0;
|
|
35
|
-
let kw;
|
|
36
|
-
while ((kw = config.indexOf('location', search)) !== -1) {
|
|
37
|
-
// Require a token boundary before `location` (start or whitespace) so
|
|
38
|
-
// `relocation` etc. don't match.
|
|
39
|
-
const boundary_ok = kw === 0 || /\s/.test(config[kw - 1]);
|
|
40
|
-
const after = kw + 'location'.length;
|
|
41
|
-
const brace = config.indexOf('{', after);
|
|
42
|
-
if (brace === -1)
|
|
43
|
-
break;
|
|
44
|
-
if (!boundary_ok) {
|
|
45
|
-
search = after;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
const header = config.slice(after, brace).trim();
|
|
49
|
-
const tokens = header.split(/\s+/).filter((token) => token.length > 0);
|
|
50
|
-
const path = tokens.length > 0 ? tokens[tokens.length - 1] : '';
|
|
51
|
-
// Brace-match the body.
|
|
52
|
-
let depth = 1;
|
|
53
|
-
let i = brace + 1;
|
|
54
|
-
while (i < config.length && depth > 0) {
|
|
55
|
-
const ch = config[i];
|
|
56
|
-
if (ch === '{')
|
|
57
|
-
depth++;
|
|
58
|
-
else if (ch === '}')
|
|
59
|
-
depth--;
|
|
60
|
-
i++;
|
|
61
|
-
}
|
|
62
|
-
blocks.push({ path, content: config.slice(brace, i) });
|
|
63
|
-
search = i;
|
|
64
|
-
}
|
|
65
|
-
return blocks;
|
|
66
|
-
};
|
|
126
|
+
import { FACT_SECURITY_HEADERS, fact_security_header_directives } from './fact_security_headers.js';
|
|
67
127
|
/**
|
|
68
|
-
* Assert the nginx
|
|
128
|
+
* Assert the nginx config serves the X-Accel facts prefix only from `internal;`
|
|
129
|
+
* locations carrying the fact hardening headers. See the module docs for the
|
|
130
|
+
* rules; every violation is collected, so one call reports them all.
|
|
69
131
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* facts location that bypasses cell visibility.
|
|
74
|
-
*
|
|
75
|
-
* @param config - the nginx config template string to check
|
|
76
|
-
* @param facts_location - the facts location path the redirect prefix points at
|
|
77
|
-
* @returns `{ok, errors}` — `ok` is `true` only when the location exists and is `internal;`
|
|
132
|
+
* @param config - the nginx config string to check
|
|
133
|
+
* @param facts_location - the facts location path the redirect prefix points at (e.g. `/_facts/`)
|
|
134
|
+
* @returns `{ok, errors}` — `ok` is `true` only when every rule passes
|
|
78
135
|
*/
|
|
79
136
|
export const validate_facts_internal_location = (config, facts_location) => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
`location serves any fact's bytes to anyone who guesses the path, bypassing every ` +
|
|
92
|
-
`cell-visibility check. Add \`internal;\` to the block.`);
|
|
137
|
+
let errors;
|
|
138
|
+
try {
|
|
139
|
+
errors = collect_errors(config, facts_location);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (!(err instanceof NginxParseError))
|
|
143
|
+
throw err;
|
|
144
|
+
errors = [
|
|
145
|
+
`could not parse the nginx config (${err.message}) — the X-Accel facts location ` +
|
|
146
|
+
`can't be verified \`internal;\`.`
|
|
147
|
+
];
|
|
93
148
|
}
|
|
94
149
|
return { ok: errors.length === 0, errors };
|
|
95
150
|
};
|
|
151
|
+
const collect_errors = (config, facts_location) => {
|
|
152
|
+
const prefix = trim_trailing_slashes(facts_location);
|
|
153
|
+
if (!facts_location.startsWith('/') || prefix === '') {
|
|
154
|
+
return [
|
|
155
|
+
`X-Accel redirect prefix \`${facts_location}\` must be an absolute path below \`/\` ` +
|
|
156
|
+
`(e.g. \`/_facts/\`).`
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
if (facts_location.length !== prefix.length + 1) {
|
|
160
|
+
return [
|
|
161
|
+
`X-Accel redirect prefix \`${facts_location}\` must end in exactly one \`/\` (e.g. ` +
|
|
162
|
+
`\`/_facts/\`) — the server appends \`<shard>/<rest>\` straight onto it, so any other ` +
|
|
163
|
+
`ending redirects outside the validated location.`
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
const directives = parse_directives(tokenize(config));
|
|
167
|
+
const locations = [];
|
|
168
|
+
collect_locations(directives, null, locations);
|
|
169
|
+
const required = Object.entries(FACT_SECURITY_HEADERS).map(([name, value]) => [
|
|
170
|
+
'add_header',
|
|
171
|
+
name,
|
|
172
|
+
value,
|
|
173
|
+
'always'
|
|
174
|
+
]);
|
|
175
|
+
const rendered = fact_security_header_directives();
|
|
176
|
+
const errors = [];
|
|
177
|
+
let at_prefix = false;
|
|
178
|
+
for (const location of locations) {
|
|
179
|
+
const label = location_label(location);
|
|
180
|
+
switch (location.modifier) {
|
|
181
|
+
case 'named':
|
|
182
|
+
break;
|
|
183
|
+
case 'prefix':
|
|
184
|
+
case 'prefix_no_regex': {
|
|
185
|
+
if (!path_is_under(location.path, prefix))
|
|
186
|
+
break;
|
|
187
|
+
// nginx compares location paths byte-for-byte, so `/_facts//` never matches
|
|
188
|
+
// `/_facts/ab/…` — only `/_facts` and `/_facts/` sit at the prefix
|
|
189
|
+
if (location.path === prefix || location.path === prefix + '/')
|
|
190
|
+
at_prefix = true;
|
|
191
|
+
if (location.modifier !== 'prefix_no_regex') {
|
|
192
|
+
errors.push(`${label} must use \`^~\` (\`location ^~ ${location.path}\`) — on a plain prefix ` +
|
|
193
|
+
`location nginx goes on to check regex locations, any of which can serve fact ` +
|
|
194
|
+
`bytes publicly.`);
|
|
195
|
+
}
|
|
196
|
+
if (location.parent !== 'server') {
|
|
197
|
+
errors.push(`${label} must be a direct child of a \`server\` block — inside another ` +
|
|
198
|
+
`block, \`^~\` doesn't stop nginx checking regex locations.`);
|
|
199
|
+
}
|
|
200
|
+
if (!has_directive(location.body, ['internal'])) {
|
|
201
|
+
errors.push(`${label} is NOT marked \`internal;\` — a public facts location serves any ` +
|
|
202
|
+
`fact's bytes to anyone who guesses the path, bypassing every ` +
|
|
203
|
+
`cell-visibility check. Add \`internal;\` as a directive of the block itself.`);
|
|
204
|
+
}
|
|
205
|
+
for (let i = 0; i < required.length; i++) {
|
|
206
|
+
if (!has_directive(location.body, required[i])) {
|
|
207
|
+
errors.push(`${label} lacks \`${rendered[i]}\` — a location's own \`add_header\` replaces the ` +
|
|
208
|
+
`server-level headers, so without it fact bytes go out without that ` +
|
|
209
|
+
`hardening header.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const directive of location.body) {
|
|
213
|
+
const name = directive.args[0] ?? '';
|
|
214
|
+
const header = directive.args[1];
|
|
215
|
+
if (directive.block === null && name === 'add_header' && header !== undefined) {
|
|
216
|
+
const index = required.findIndex((args) => to_ascii_lowercase(args[1]) === to_ascii_lowercase(header));
|
|
217
|
+
if (index !== -1 && !args_equal(directive.args, required[index])) {
|
|
218
|
+
errors.push(`${label} has another \`add_header ${header}\` (line ${directive.line}) — nginx ` +
|
|
219
|
+
`sends every \`add_header\` for a header and a client may read only the ` +
|
|
220
|
+
`first, so the facts location may set \`${required[index][1]}\` only with ` +
|
|
221
|
+
`\`${rendered[index]}\`.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (directive.block !== null) {
|
|
225
|
+
errors.push(`${label} contains a nested \`${name}\` block (line ${directive.line}) — the facts ` +
|
|
226
|
+
`location must hold only simple directives, since a nested block can ` +
|
|
227
|
+
`serve or re-header the bytes.`);
|
|
228
|
+
}
|
|
229
|
+
else if (name === 'include') {
|
|
230
|
+
errors.push(`${label} contains \`include\` (line ${directive.line}) — included files are not ` +
|
|
231
|
+
`followed, so the facts location can't contain one.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'exact':
|
|
237
|
+
if (path_is_under(location.path, prefix)) {
|
|
238
|
+
errors.push(`${label} is an exact-match location on the facts prefix — it can't serve ` +
|
|
239
|
+
`the nested \`<shard>/<rest>\` paths the redirect emits, and one below the ` +
|
|
240
|
+
`prefix shadows the internal location. Use a \`^~\` location.`);
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
case 'regex':
|
|
244
|
+
case 'regex_caseless': {
|
|
245
|
+
const pattern = location.path.replaceAll('\\/', '/');
|
|
246
|
+
const shadows = location.modifier === 'regex_caseless'
|
|
247
|
+
? to_ascii_lowercase(pattern).includes(to_ascii_lowercase(prefix))
|
|
248
|
+
: pattern.includes(prefix);
|
|
249
|
+
if (shadows) {
|
|
250
|
+
errors.push(`${label} is a regex location naming the facts prefix — remove it; the ` +
|
|
251
|
+
`facts location must be a server-level \`^~\` location, which nginx ` +
|
|
252
|
+
`serves without checking regex locations.`);
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
collect_prefix_mention_errors(directives, prefix, errors);
|
|
259
|
+
collect_module_errors(directives, prefix, true, errors);
|
|
260
|
+
if (!at_prefix) {
|
|
261
|
+
errors.unshift(`No nginx \`location ^~ ${facts_location}\` block found — the X-Accel facts ` +
|
|
262
|
+
`location must exist exactly at the prefix (one only below it leaves the other ` +
|
|
263
|
+
`redirect paths uncovered) and be marked \`internal;\` so only the authz'd ` +
|
|
264
|
+
`handler's redirect can reach it. A public facts location bypasses every ` +
|
|
265
|
+
`cell-visibility check.`);
|
|
266
|
+
}
|
|
267
|
+
return errors;
|
|
268
|
+
};
|
|
96
269
|
/**
|
|
97
270
|
* A validated X-Accel redirect configuration — the only handle that enables the
|
|
98
271
|
* `X-Accel-Redirect` serving path in `server/serve_fact_route.ts`.
|
|
99
272
|
*
|
|
100
273
|
* The redirect prefix can be obtained **only** by passing the nginx config
|
|
101
274
|
* through `validate_facts_internal_location` (via `create_x_accel_config`), so
|
|
102
|
-
* X-Accel serving is impossible to enable without proving
|
|
103
|
-
* `internal;`
|
|
104
|
-
* cell-visibility check. A
|
|
105
|
-
*
|
|
275
|
+
* X-Accel serving is impossible to enable without proving at boot that the
|
|
276
|
+
* facts location is `internal;` and carries the fact hardening headers — a
|
|
277
|
+
* public facts location would bypass every cell-visibility check. A
|
|
278
|
+
* Zod-branded type: the brand can't be forged without an explicit cast, so the
|
|
279
|
+
* factory is the only ordinary construction path.
|
|
106
280
|
*/
|
|
107
281
|
export const XAccelConfig = z
|
|
108
282
|
.strictObject({
|
|
@@ -124,18 +298,19 @@ export class XAccelConfigError extends Error {
|
|
|
124
298
|
}
|
|
125
299
|
}
|
|
126
300
|
/**
|
|
127
|
-
* Build a validated `XAccelConfig`, asserting the nginx
|
|
128
|
-
* `redirect_prefix`
|
|
301
|
+
* Build a validated `XAccelConfig`, asserting the nginx config serves
|
|
302
|
+
* `redirect_prefix` only from safe locations (the module-level rules).
|
|
129
303
|
*
|
|
130
304
|
* The make-impossible-states gate: serving can only emit `X-Accel-Redirect`
|
|
131
|
-
* into a location proven `internal;` at boot.
|
|
305
|
+
* into a location proven `internal;` and header-carrying at boot.
|
|
132
306
|
*
|
|
133
|
-
* @param redirect_prefix - the X-Accel redirect prefix (e.g. `/_facts/`)
|
|
134
|
-
* @param nginx_config - the nginx config
|
|
307
|
+
* @param redirect_prefix - the X-Accel redirect prefix, ending in `/` (e.g. `/_facts/`)
|
|
308
|
+
* @param nginx_config - the nginx config string to validate against
|
|
135
309
|
* @returns the validated `XAccelConfig` carrying `redirect_prefix`
|
|
136
|
-
* @throws `XAccelConfigError`
|
|
137
|
-
* `
|
|
138
|
-
*
|
|
310
|
+
* @throws `XAccelConfigError` carrying every violation
|
|
311
|
+
* `validate_facts_internal_location` finds — a prefix not ending in `/`, a
|
|
312
|
+
* missing, public, header-less, re-headered, exact-match, or regex-shadowed
|
|
313
|
+
* facts location, or `ssi` / headers-more reaching it.
|
|
139
314
|
*/
|
|
140
315
|
export const create_x_accel_config = (redirect_prefix, nginx_config) => {
|
|
141
316
|
const validation = validate_facts_internal_location(nginx_config, redirect_prefix);
|
|
@@ -144,3 +319,392 @@ export const create_x_accel_config = (redirect_prefix, nginx_config) => {
|
|
|
144
319
|
}
|
|
145
320
|
return XAccelConfig.parse({ redirect_prefix });
|
|
146
321
|
};
|
|
322
|
+
/** Strip every trailing `/` so `/_facts/` and `/_facts` compare equal. */
|
|
323
|
+
const trim_trailing_slashes = (value) => value.replace(/\/+$/, '');
|
|
324
|
+
/** ASCII-only lowercasing, so both twins fold exactly the same characters. */
|
|
325
|
+
const to_ascii_lowercase = (value) => value.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
326
|
+
/** `true` when `path` is `prefix` or below it, trailing slashes ignored. */
|
|
327
|
+
const path_is_under = (path, prefix) => {
|
|
328
|
+
const trimmed = trim_trailing_slashes(path);
|
|
329
|
+
return trimmed === prefix || (trimmed.startsWith(prefix) && trimmed[prefix.length] === '/');
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* `true` when `body` has a top-level simple directive whose words are exactly
|
|
333
|
+
* `args` (a block directive with those words doesn't count).
|
|
334
|
+
*/
|
|
335
|
+
const has_directive = (body, args) => body.some((d) => d.block === null && args_equal(d.args, args));
|
|
336
|
+
/** `true` when `a` and `b` hold the same words in the same order. */
|
|
337
|
+
const args_equal = (a, b) => a.length === b.length && a.every((word, i) => word === b[i]);
|
|
338
|
+
const MODIFIER_TEXT = {
|
|
339
|
+
prefix: '',
|
|
340
|
+
named: '',
|
|
341
|
+
prefix_no_regex: '^~ ',
|
|
342
|
+
exact: '= ',
|
|
343
|
+
regex: '~ ',
|
|
344
|
+
regex_caseless: '~* '
|
|
345
|
+
};
|
|
346
|
+
/** The modifiers nginx accepts as a separate word (`= /x`). */
|
|
347
|
+
const SEPARATE_MODIFIERS = new Map([
|
|
348
|
+
['=', 'exact'],
|
|
349
|
+
['^~', 'prefix_no_regex'],
|
|
350
|
+
['~', 'regex'],
|
|
351
|
+
['~*', 'regex_caseless']
|
|
352
|
+
]);
|
|
353
|
+
/**
|
|
354
|
+
* The modifiers nginx accepts glued to the path (`=/x`), longest first so `^~`
|
|
355
|
+
* / `~*` aren't read as `~`.
|
|
356
|
+
*/
|
|
357
|
+
const GLUED_MODIFIERS = [
|
|
358
|
+
['^~', 'prefix_no_regex'],
|
|
359
|
+
['~*', 'regex_caseless'],
|
|
360
|
+
['~', 'regex'],
|
|
361
|
+
['=', 'exact']
|
|
362
|
+
];
|
|
363
|
+
const location_label = (location) => `\`location ${MODIFIER_TEXT[location.modifier]}${location.path}\` (line ${location.line})`;
|
|
364
|
+
/**
|
|
365
|
+
* Classify a `location`'s arguments (after the keyword) — the separate (`= /x`)
|
|
366
|
+
* and glued (`=/x`) modifier forms nginx accepts.
|
|
367
|
+
*/
|
|
368
|
+
const parse_location_args = (args) => {
|
|
369
|
+
if (args.length === 2) {
|
|
370
|
+
const modifier = SEPARATE_MODIFIERS.get(args[0]);
|
|
371
|
+
return modifier === undefined ? null : { modifier, path: args[1] };
|
|
372
|
+
}
|
|
373
|
+
if (args.length === 1) {
|
|
374
|
+
const glued = args[0];
|
|
375
|
+
if (glued.startsWith('@'))
|
|
376
|
+
return { modifier: 'named', path: glued };
|
|
377
|
+
for (const [text, modifier] of GLUED_MODIFIERS) {
|
|
378
|
+
if (glued.startsWith(text))
|
|
379
|
+
return { modifier, path: glued.slice(text.length) };
|
|
380
|
+
}
|
|
381
|
+
return { modifier: 'prefix', path: glued };
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* Refuse every directive, in any block at any depth, with a word containing the
|
|
387
|
+
* prefix (trailing slash trimmed, so `/_facts$uri` counts) — the prefix may
|
|
388
|
+
* appear only in its own location lines and their bodies. Exempt: each
|
|
389
|
+
* `location` directive's own modifier and path words, and the bodies of the
|
|
390
|
+
* locations serving the prefix (held to simple directives elsewhere). A textual
|
|
391
|
+
* heuristic against operator mistakes: a value assembled from pieces
|
|
392
|
+
* (`/_fac$1`) is not caught.
|
|
393
|
+
*/
|
|
394
|
+
const collect_prefix_mention_errors = (directives, prefix, errors) => {
|
|
395
|
+
for (const directive of directives) {
|
|
396
|
+
const name = directive.args[0] ?? '';
|
|
397
|
+
if (name === 'location') {
|
|
398
|
+
const parsed = parse_location_args(directive.args.slice(1));
|
|
399
|
+
const serves_prefix = parsed !== null &&
|
|
400
|
+
(parsed.modifier === 'prefix' || parsed.modifier === 'prefix_no_regex') &&
|
|
401
|
+
path_is_under(parsed.path, prefix);
|
|
402
|
+
if (directive.block !== null && !serves_prefix) {
|
|
403
|
+
collect_prefix_mention_errors(directive.block, prefix, errors);
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (directive.args.some((word) => word.includes(prefix))) {
|
|
408
|
+
errors.push(`\`${name}\` (line ${directive.line}) names the facts prefix — the prefix may appear only in its ` +
|
|
409
|
+
`own location lines and their bodies; anything else naming it (a redirect, a ` +
|
|
410
|
+
`subrequest, a variable that becomes one) can serve fact bytes publicly.`);
|
|
411
|
+
}
|
|
412
|
+
if (directive.block !== null)
|
|
413
|
+
collect_prefix_mention_errors(directive.block, prefix, errors);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* The headers-more module's response-header directives, which run after
|
|
418
|
+
* `add_header` and so can strip or rewrite the fact hardening headers.
|
|
419
|
+
*/
|
|
420
|
+
const HEADERS_MORE_DIRECTIVES = new Set([
|
|
421
|
+
'more_set_headers',
|
|
422
|
+
'more_clear_headers'
|
|
423
|
+
]);
|
|
424
|
+
/**
|
|
425
|
+
* Refuse the module directives that undo the facts location's guarantees:
|
|
426
|
+
*
|
|
427
|
+
* - `ssi` with any value but `off`, in any block at any depth. SSI `include
|
|
428
|
+
* virtual` is a subrequest, which `internal;` admits, so an SSI-processed
|
|
429
|
+
* response — a fact served from the facts location (inheriting `ssi on` from
|
|
430
|
+
* `http` or `server`), or any page carrying user content — could include
|
|
431
|
+
* another fact's bytes by its path. A sibling location's `ssi on` is as
|
|
432
|
+
* dangerous as an inherited one, so the rule is config-wide.
|
|
433
|
+
* - a headers-more directive anywhere except inside a `location` that doesn't
|
|
434
|
+
* serve the prefix (or a block within one): in the facts location's body, or
|
|
435
|
+
* at any level above it (`http`, `server`, top level, and any block there such
|
|
436
|
+
* as `if` or another `server`).
|
|
437
|
+
*
|
|
438
|
+
* `reaches_facts` is `true` while the walk is in a scope the facts location
|
|
439
|
+
* inherits from or is.
|
|
440
|
+
*/
|
|
441
|
+
const collect_module_errors = (directives, prefix, reaches_facts, errors) => {
|
|
442
|
+
for (const directive of directives) {
|
|
443
|
+
const name = directive.args[0] ?? '';
|
|
444
|
+
if (directive.block === null) {
|
|
445
|
+
if (name === 'ssi' &&
|
|
446
|
+
!(directive.args.length === 2 && to_ascii_lowercase(directive.args[1]) === 'off')) {
|
|
447
|
+
errors.push(`\`ssi\` (line ${directive.line}) is not \`off\` — SSI \`include virtual\` subrequests ` +
|
|
448
|
+
`reach the \`internal;\` facts location, so an SSI-processed response could ` +
|
|
449
|
+
`include any fact's bytes by its path. Remove it or set \`ssi off;\`.`);
|
|
450
|
+
}
|
|
451
|
+
else if (reaches_facts && HEADERS_MORE_DIRECTIVES.has(name)) {
|
|
452
|
+
errors.push(`\`${name}\` (line ${directive.line}) applies to the facts location — a ` +
|
|
453
|
+
`headers-more directive in or above it can strip or rewrite the fact hardening ` +
|
|
454
|
+
`headers after \`add_header\` sets them. Move it into the locations that need it.`);
|
|
455
|
+
}
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
let reaches = reaches_facts;
|
|
459
|
+
if (name === 'location') {
|
|
460
|
+
const parsed = parse_location_args(directive.args.slice(1));
|
|
461
|
+
reaches =
|
|
462
|
+
parsed !== null &&
|
|
463
|
+
(parsed.modifier === 'prefix' || parsed.modifier === 'prefix_no_regex') &&
|
|
464
|
+
path_is_under(parsed.path, prefix);
|
|
465
|
+
}
|
|
466
|
+
collect_module_errors(directive.block, prefix, reaches, errors);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
/**
|
|
470
|
+
* Gather every `location` block at any depth, in document order, each with the
|
|
471
|
+
* name of the block directly enclosing it.
|
|
472
|
+
*/
|
|
473
|
+
const collect_locations = (directives, parent, out) => {
|
|
474
|
+
for (const directive of directives) {
|
|
475
|
+
if (directive.block === null)
|
|
476
|
+
continue;
|
|
477
|
+
const name = directive.args[0] ?? '';
|
|
478
|
+
if (name === 'location') {
|
|
479
|
+
const args = directive.args.slice(1);
|
|
480
|
+
const parsed = parse_location_args(args);
|
|
481
|
+
if (parsed === null) {
|
|
482
|
+
throw new NginxParseError(`unrecognised \`location ${args.join(' ')}\` (line ${directive.line})`);
|
|
483
|
+
}
|
|
484
|
+
out.push({ ...parsed, line: directive.line, parent, body: directive.block });
|
|
485
|
+
}
|
|
486
|
+
collect_locations(directive.block, name, out);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
/** A config the tokenizer or parser can't read — reported as a validation error. */
|
|
490
|
+
class NginxParseError extends Error {
|
|
491
|
+
}
|
|
492
|
+
/** nginx's config whitespace — exactly these four, nothing Unicode. */
|
|
493
|
+
const is_nginx_space = (ch) => ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n';
|
|
494
|
+
/**
|
|
495
|
+
* Apply nginx's word unescaping: `\"` `\'` `\\` drop the backslash, `\t` `\r`
|
|
496
|
+
* `\n` become the control character, any other backslash stays.
|
|
497
|
+
*/
|
|
498
|
+
const unescape_word = (raw) => {
|
|
499
|
+
let out = '';
|
|
500
|
+
for (let i = 0; i < raw.length; i++) {
|
|
501
|
+
const c = raw[i];
|
|
502
|
+
if (c === '\\') {
|
|
503
|
+
const next = raw[i + 1];
|
|
504
|
+
const replacement = next === '"' || next === "'" || next === '\\'
|
|
505
|
+
? next
|
|
506
|
+
: next === 't'
|
|
507
|
+
? '\t'
|
|
508
|
+
: next === 'r'
|
|
509
|
+
? '\r'
|
|
510
|
+
: next === 'n'
|
|
511
|
+
? '\n'
|
|
512
|
+
: undefined;
|
|
513
|
+
if (replacement !== undefined) {
|
|
514
|
+
out += replacement;
|
|
515
|
+
i++;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
out += c;
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
};
|
|
523
|
+
/**
|
|
524
|
+
* Tokenize as nginx's `ngx_conf_read_token` reads a config — the same state
|
|
525
|
+
* machine (`last_space`, `need_space`, `quoted`, `variable`, the quote and
|
|
526
|
+
* comment flags), producing words and the `;` `{` `}` punctuation. Iterates
|
|
527
|
+
* code points, as the Rust twin iterates `char`s.
|
|
528
|
+
*/
|
|
529
|
+
const tokenize = (config) => {
|
|
530
|
+
const tokens = [];
|
|
531
|
+
let line = 1;
|
|
532
|
+
let start_line = 1;
|
|
533
|
+
let word = '';
|
|
534
|
+
let last_space = true;
|
|
535
|
+
let need_space = false;
|
|
536
|
+
let quoted = false;
|
|
537
|
+
let s_quoted = false;
|
|
538
|
+
let d_quoted = false;
|
|
539
|
+
let sharp_comment = false;
|
|
540
|
+
let variable = false;
|
|
541
|
+
for (const ch of config) {
|
|
542
|
+
if (ch === '\n') {
|
|
543
|
+
line++;
|
|
544
|
+
sharp_comment = false;
|
|
545
|
+
}
|
|
546
|
+
if (sharp_comment)
|
|
547
|
+
continue;
|
|
548
|
+
if (quoted) {
|
|
549
|
+
quoted = false;
|
|
550
|
+
word += ch;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (need_space) {
|
|
554
|
+
if (is_nginx_space(ch)) {
|
|
555
|
+
last_space = true;
|
|
556
|
+
need_space = false;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (ch === ';' || ch === '{') {
|
|
560
|
+
need_space = false;
|
|
561
|
+
last_space = true;
|
|
562
|
+
tokens.push({ kind: ch, line });
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (ch === ')') {
|
|
566
|
+
last_space = true;
|
|
567
|
+
need_space = false;
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
throw new NginxParseError(`unexpected \`${ch}\` after a quoted string, line ${line}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (last_space) {
|
|
574
|
+
start_line = line;
|
|
575
|
+
if (is_nginx_space(ch))
|
|
576
|
+
continue;
|
|
577
|
+
if (ch === ';' || ch === '{' || ch === '}') {
|
|
578
|
+
tokens.push({ kind: ch, line });
|
|
579
|
+
}
|
|
580
|
+
else if (ch === '#') {
|
|
581
|
+
sharp_comment = true;
|
|
582
|
+
}
|
|
583
|
+
else if (ch === '"') {
|
|
584
|
+
d_quoted = true;
|
|
585
|
+
last_space = false;
|
|
586
|
+
}
|
|
587
|
+
else if (ch === "'") {
|
|
588
|
+
s_quoted = true;
|
|
589
|
+
last_space = false;
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
if (ch === '\\') {
|
|
593
|
+
quoted = true;
|
|
594
|
+
}
|
|
595
|
+
else if (ch === '$') {
|
|
596
|
+
variable = true;
|
|
597
|
+
}
|
|
598
|
+
last_space = false;
|
|
599
|
+
word += ch;
|
|
600
|
+
}
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (ch === '{' && variable) {
|
|
604
|
+
word += ch;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
variable = false;
|
|
608
|
+
if (ch === '\\') {
|
|
609
|
+
quoted = true;
|
|
610
|
+
word += ch;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (ch === '$') {
|
|
614
|
+
variable = true;
|
|
615
|
+
word += ch;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
let found = false;
|
|
619
|
+
if (d_quoted) {
|
|
620
|
+
if (ch === '"') {
|
|
621
|
+
d_quoted = false;
|
|
622
|
+
need_space = true;
|
|
623
|
+
found = true;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
else if (s_quoted) {
|
|
627
|
+
if (ch === "'") {
|
|
628
|
+
s_quoted = false;
|
|
629
|
+
need_space = true;
|
|
630
|
+
found = true;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
else if (is_nginx_space(ch) || ch === ';' || ch === '{') {
|
|
634
|
+
last_space = true;
|
|
635
|
+
found = true;
|
|
636
|
+
}
|
|
637
|
+
if (!found) {
|
|
638
|
+
word += ch;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
tokens.push({ kind: 'word', text: unescape_word(word), line: start_line });
|
|
642
|
+
word = '';
|
|
643
|
+
if (ch === ';' || ch === '{')
|
|
644
|
+
tokens.push({ kind: ch, line });
|
|
645
|
+
}
|
|
646
|
+
if (!last_space) {
|
|
647
|
+
throw new NginxParseError(`unexpected end of file inside a word, line ${line}`);
|
|
648
|
+
}
|
|
649
|
+
return tokens;
|
|
650
|
+
};
|
|
651
|
+
/**
|
|
652
|
+
* The deepest block nesting the parser accepts — far beyond any real config, and
|
|
653
|
+
* low enough that the recursive walks can't exhaust the stack.
|
|
654
|
+
*/
|
|
655
|
+
const MAX_NESTING_DEPTH = 64;
|
|
656
|
+
const parse_directives = (tokens) => {
|
|
657
|
+
const cursor = { pos: 0 };
|
|
658
|
+
return parse_block(tokens, cursor, null, 0);
|
|
659
|
+
};
|
|
660
|
+
/**
|
|
661
|
+
* Parse one block's directives; `depth` is the number of blocks enclosing them
|
|
662
|
+
* (`0` at the top level).
|
|
663
|
+
*/
|
|
664
|
+
const parse_block = (tokens, cursor, open_line, depth) => {
|
|
665
|
+
const out = [];
|
|
666
|
+
for (;;) {
|
|
667
|
+
const token = tokens[cursor.pos];
|
|
668
|
+
if (token === undefined) {
|
|
669
|
+
if (open_line !== null) {
|
|
670
|
+
throw new NginxParseError(`unclosed \`{\` opened on line ${open_line}`);
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
if (token.kind === '}') {
|
|
675
|
+
if (open_line === null)
|
|
676
|
+
throw new NginxParseError(`unexpected \`}\` on line ${token.line}`);
|
|
677
|
+
cursor.pos++;
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
if (token.kind !== 'word') {
|
|
681
|
+
throw new NginxParseError(`unexpected \`${token.kind}\` on line ${token.line}`);
|
|
682
|
+
}
|
|
683
|
+
const line = token.line;
|
|
684
|
+
const args = [];
|
|
685
|
+
for (;;) {
|
|
686
|
+
const next = tokens[cursor.pos];
|
|
687
|
+
if (next?.kind === 'word') {
|
|
688
|
+
args.push(next.text);
|
|
689
|
+
cursor.pos++;
|
|
690
|
+
}
|
|
691
|
+
else if (next?.kind === ';') {
|
|
692
|
+
cursor.pos++;
|
|
693
|
+
out.push({ args, line, block: null });
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
else if (next?.kind === '{') {
|
|
697
|
+
if (depth >= MAX_NESTING_DEPTH) {
|
|
698
|
+
throw new NginxParseError(`blocks nested deeper than ${MAX_NESTING_DEPTH} levels (line ${next.line})`);
|
|
699
|
+
}
|
|
700
|
+
cursor.pos++;
|
|
701
|
+
const block = parse_block(tokens, cursor, next.line, depth + 1);
|
|
702
|
+
out.push({ args, line, block });
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
throw new NginxParseError(`directive \`${args.join(' ')}\` on line ${line} is missing its \`;\``);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
};
|