@faable/faable 3.1.1 → 3.2.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.
@@ -5,45 +5,59 @@ const describe_selector = (s) => {
5
5
  if (s.pattern && s.user_agent) {
6
6
  return `requests for ${s.pattern} from user-agents matching ${s.user_agent}`;
7
7
  }
8
+ if (s.pattern && s.query) {
9
+ return `requests for ${s.pattern} carrying ?${s.query}`;
10
+ }
8
11
  if (s.user_agent)
9
12
  return `requests from user-agents matching ${s.user_agent}`;
13
+ if (s.query)
14
+ return `requests carrying ?${s.query}`;
10
15
  return `requests for ${s.pattern}`;
11
16
  };
12
17
  /** Does this returned rule carry everything we asked for? */
13
18
  const rule_matches_request = (rule, want, action) => rule.action === action &&
14
19
  (rule.pattern ?? '') === (want.pattern ?? '') &&
15
- (rule.user_agent ?? '') === (want.user_agent ?? '');
20
+ (rule.user_agent ?? '') === (want.user_agent ?? '') &&
21
+ (rule.query ?? '') === (want.query ?? '');
16
22
  /**
17
23
  * Shared handler for `block` and `sink` — the two differ only in the action
18
24
  * they store and in what the edge answers, so the flow (resolve app, write,
19
25
  * verify, explain, tell the user how to check) lives here once.
20
26
  */
21
27
  const add_rule = async (opts) => {
22
- const { api, app_id, app_name, app_url, pattern, user_agent, action, description, force } = opts;
23
- const want = { pattern, user_agent };
28
+ const { api, app_id, app_name, app_url, pattern, user_agent, query, action, description, force } = opts;
29
+ const want = { pattern, user_agent, query };
24
30
  const waf = await api.addAppWafRule(app_id, {
25
31
  pattern,
26
32
  user_agent,
33
+ query,
27
34
  action,
28
35
  description,
29
36
  force
30
37
  });
31
38
  // Read the rule back before telling the user it worked.
32
39
  //
33
- // An older Faable server has no `user_agent` on this endpoint and drops it
34
- // silently, storing the path half on its own — which turns "block /login for
35
- // this bot" into "block /login for everyone". No server-side schema can
36
- // prevent that (the old server is the old code), so the client checks that
37
- // what came back is what it asked for, and undoes the write if it is not.
38
- if (user_agent &&
40
+ // An older Faable server has no `user_agent` (or no `query`) on this endpoint
41
+ // and drops it silently, storing the path half on its own — which turns
42
+ // "block /login for this bot" into "block /login for everyone", or "block /
43
+ // when it carries ?rest_route" into "block /", which is the entire app. No
44
+ // server-side schema can prevent that (the old server is the old code), so
45
+ // the client checks that what came back is what it asked for, and undoes the
46
+ // write if it is not.
47
+ //
48
+ // The query half is the more dangerous of the two, because the api LIFTS the
49
+ // site-root guard for a path that is ANDed with a parameter: `^/$` is a legal
50
+ // pattern to send, and an old server that drops the parameter stores exactly
51
+ // the rule the guard exists to refuse.
52
+ if ((user_agent || query) &&
39
53
  !waf.rules.some(r => rule_matches_request(r, want, action))) {
40
54
  await api
41
- .removeAppWafRule(app_id, { pattern, user_agent, action })
55
+ .removeAppWafRule(app_id, { pattern, user_agent, query, action })
42
56
  // If the rollback itself is unsupported, fall back to the path-only
43
57
  // shape the old server actually stored.
44
58
  .catch(() => api.removeAppWafRule(app_id, { pattern, action }))
45
59
  .catch(() => undefined);
46
- throw new Error(`This Faable server does not support user-agent rules yet, so the rule was not created.\n` +
60
+ throw new Error(`This Faable server does not support ${query ? 'query-parameter' : 'user-agent'} rules yet, so the rule was not created.\n` +
47
61
  ` Update the CLI and try again, or ask support to upgrade the platform.`);
48
62
  }
49
63
  const answer = action === 'deny' ? '403' : '404';
@@ -75,7 +89,8 @@ const add_rule = async (opts) => {
75
89
  /** The exact arguments that reverse this rule. */
76
90
  const undo_args = (s) => [
77
91
  s.pattern ? `'${s.pattern}'` : '',
78
- s.user_agent ? `--user-agent '${s.user_agent}'` : ''
92
+ s.user_agent ? `--user-agent '${s.user_agent}'` : '',
93
+ s.query ? `--query '${s.query}'` : ''
79
94
  ]
80
95
  .filter(Boolean)
81
96
  .join(' ');
@@ -14,6 +14,11 @@ const waf_block = {
14
14
  alias: 'u',
15
15
  type: 'string',
16
16
  description: 'RE2 fragment matched against User-Agent, e.g. YisouSpider. With a path, BOTH must match.'
17
+ })
18
+ .option('query', {
19
+ alias: 'q',
20
+ type: 'string',
21
+ description: 'Literal query parameter name, e.g. rest_route (not a regex). With a path, BOTH must match.'
17
22
  })
18
23
  .option('app', {
19
24
  alias: 'a',
@@ -28,19 +33,24 @@ const waf_block = {
28
33
  .option('force', {
29
34
  type: 'boolean',
30
35
  default: false,
31
- description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor'
36
+ description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor, or a query parameter that carries an auth flow'
32
37
  })
33
- .check(({ pattern, userAgent }) => {
34
- if (!pattern && !userAgent) {
35
- throw new Error('Give a path pattern, --user-agent, or both.\n' +
38
+ .check(({ pattern, userAgent, query }) => {
39
+ if (userAgent && query) {
40
+ throw new Error('A rule can combine a path with --user-agent OR with --query, not both.');
41
+ }
42
+ if (!pattern && !userAgent && !query) {
43
+ throw new Error('Give a path pattern, --user-agent, --query, or a path with one of them.\n' +
36
44
  " e.g. faable deploy waf block '^/wp-admin'\n" +
37
- ' faable deploy waf block --user-agent YisouSpider');
45
+ ' faable deploy waf block --user-agent YisouSpider\n' +
46
+ ' faable deploy waf block --query rest_route');
38
47
  }
39
48
  return true;
40
49
  })
41
50
  .example("$0 deploy waf block '^/\\.well-known/'", 'Stop scanner probes under /.well-known from waking the app')
42
51
  .example('$0 deploy waf block --user-agent YisouSpider', 'Refuse one crawler outright, on every path')
43
52
  .example("$0 deploy waf block '^/api/' --user-agent curl", 'Refuse /api/ to that user-agent only, leaving browsers untouched')
53
+ .example('$0 deploy waf block --query rest_route', 'Stop WordPress REST probes, whose payload rides in the query string')
44
54
  .showHelpOnFail(false),
45
55
  handler: async (args) => {
46
56
  const ctx = await requireApi();
@@ -53,6 +63,7 @@ const waf_block = {
53
63
  app_url: app.url,
54
64
  pattern: args.pattern,
55
65
  user_agent: args.userAgent,
66
+ query: args.query,
56
67
  action: 'deny',
57
68
  description: args.description,
58
69
  force: args.force
@@ -26,10 +26,18 @@ const format_waf = (waf) => {
26
26
  for (const p of waf.platform_profiles) {
27
27
  // Say what the ruleset matches ON, not just what it answers: without it a
28
28
  // `probe` profile of user-agents reads as if it blocked paths.
29
- const on = p.match === 'user_agent' ? ' by user-agent' : '';
29
+ const on = p.match === 'user_agent'
30
+ ? ' by user-agent'
31
+ : p.match === 'query'
32
+ ? ' by query parameter'
33
+ : '';
30
34
  out.push(` • ${p.name} — ${p.rule_count} rule(s)${on}, ${action_label(p.action)}`);
31
35
  for (const r of p.rules ?? []) {
32
- const what = p.match === 'user_agent' ? `UA ~ ${r.pattern}` : r.pattern;
36
+ const what = p.match === 'user_agent'
37
+ ? `UA ~ ${r.pattern}`
38
+ : p.match === 'query'
39
+ ? `?${r.pattern}`
40
+ : r.pattern;
33
41
  out.push(` ${what}${r.description ? ` # ${r.description}` : ''}`);
34
42
  }
35
43
  }
@@ -48,16 +56,22 @@ const format_waf = (waf) => {
48
56
  /**
49
57
  * What a rule selects, in one line.
50
58
  *
51
- * The three shapes have to be visibly different here: a user-agent rule has no
52
- * path restriction (it applies to the whole app) and a combined one is an AND,
53
- * so printing either as a bare pattern would misrepresent its reach.
59
+ * The five shapes have to be visibly different here: a user-agent or query rule
60
+ * has no path restriction (it applies to the whole app) and the combined ones
61
+ * are ANDs, so printing any of them as a bare pattern would misrepresent its
62
+ * reach. The `?` prefix marks a query parameter — `rest_route` on its own would
63
+ * read like a path.
54
64
  */
55
65
  const rule_subject = (r) => {
56
66
  if (r.pattern && r.user_agent) {
57
67
  return `${r.pattern} + UA ~ ${r.user_agent}`;
58
68
  }
69
+ if (r.pattern && r.query)
70
+ return `${r.pattern} + ?${r.query}`;
59
71
  if (r.user_agent)
60
72
  return `UA ~ ${r.user_agent} (any path)`;
73
+ if (r.query)
74
+ return `?${r.query} (any path)`;
61
75
  return r.pattern ?? '(unknown)';
62
76
  };
63
77
 
@@ -18,6 +18,11 @@ const waf_rm = {
18
18
  alias: 'u',
19
19
  type: 'string',
20
20
  description: 'The user-agent of the rule to remove'
21
+ })
22
+ .option('query', {
23
+ alias: 'q',
24
+ type: 'string',
25
+ description: 'The query parameter of the rule to remove'
21
26
  })
22
27
  .option('action', {
23
28
  type: 'string',
@@ -29,9 +34,9 @@ const waf_rm = {
29
34
  type: 'string',
30
35
  description: 'App Identifier (defaults to the linked app)'
31
36
  })
32
- .check(({ pattern, userAgent }) => {
33
- if (!pattern && !userAgent) {
34
- throw new Error('Give the path pattern, --user-agent, or both.');
37
+ .check(({ pattern, userAgent, query }) => {
38
+ if (!pattern && !userAgent && !query) {
39
+ throw new Error('Give the path pattern, --user-agent, --query, or a path with one of them.');
35
40
  }
36
41
  return true;
37
42
  })
@@ -45,11 +50,13 @@ const waf_rm = {
45
50
  await ctx.api.removeAppWafRule(app_id, {
46
51
  pattern: args.pattern,
47
52
  user_agent: args.userAgent,
53
+ query: args.query,
48
54
  action: args.action
49
55
  });
50
56
  log.info(`🗑️ Removed ${describe_selector({
51
57
  pattern: args.pattern,
52
- user_agent: args.userAgent
58
+ user_agent: args.userAgent,
59
+ query: args.query
53
60
  })} from ${app.name} (${app_id}).`);
54
61
  log.info(`They reach your app again within ~20s, once the edge picks up the change.`);
55
62
  }
@@ -14,6 +14,11 @@ const waf_sink = {
14
14
  alias: 'u',
15
15
  type: 'string',
16
16
  description: 'RE2 fragment matched against User-Agent, e.g. YisouSpider. With a path, BOTH must match.'
17
+ })
18
+ .option('query', {
19
+ alias: 'q',
20
+ type: 'string',
21
+ description: 'Literal query parameter name, e.g. rest_route (not a regex). With a path, BOTH must match.'
17
22
  })
18
23
  .option('app', {
19
24
  alias: 'a',
@@ -28,13 +33,17 @@ const waf_sink = {
28
33
  .option('force', {
29
34
  type: 'boolean',
30
35
  default: false,
31
- description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor'
36
+ description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor, or a query parameter that carries an auth flow'
32
37
  })
33
- .check(({ pattern, userAgent }) => {
34
- if (!pattern && !userAgent) {
35
- throw new Error('Give a path pattern, --user-agent, or both.\n' +
38
+ .check(({ pattern, userAgent, query }) => {
39
+ if (userAgent && query) {
40
+ throw new Error('A rule can combine a path with --user-agent OR with --query, not both.');
41
+ }
42
+ if (!pattern && !userAgent && !query) {
43
+ throw new Error('Give a path pattern, --user-agent, --query, or a path with one of them.\n' +
36
44
  " e.g. faable deploy waf sink '^/wp-admin'\n" +
37
- ' faable deploy waf sink --user-agent YisouSpider');
45
+ ' faable deploy waf sink --user-agent YisouSpider\n' +
46
+ ' faable deploy waf sink --query rest_route');
38
47
  }
39
48
  return true;
40
49
  })
@@ -55,6 +64,7 @@ const waf_sink = {
55
64
  app_url: app.url,
56
65
  pattern: args.pattern,
57
66
  user_agent: args.userAgent,
67
+ query: args.query,
58
68
  action: 'sink',
59
69
  description: args.description,
60
70
  force: args.force
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",