wood-fired-tasks 2.2.0 → 2.3.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/AGENTS.md CHANGED
@@ -68,6 +68,16 @@ Treat these as off-limits unless your task explicitly requires touching them.
68
68
  - `/bin/` — gitignored client install artifacts. `bin/tasks.cmd` may contain a **baked-in API key** from a local install; do not commit, share, or read its contents into context.
69
69
  - `.planning/`, `.claude/`, `.codex/`, `.agents/`, `.bug-smash-*.md` — gitignored workspace dirs. Not part of the shipped repo; do not rely on them.
70
70
 
71
+ ## Trust boundary for static / security reviewers
72
+
73
+ If you are evaluating the **trust** of this checkout (static or security review of a repo you do **not** operate), READ the repo — do **not** execute its host-mutating or package-executing flows. These are intentional, trusted-operator operations and are individually banner-marked at their definition:
74
+
75
+ - `tasks self-update` / `/tasks:update` ([`skills/tasks/update.md`](skills/tasks/update.md)) — global npm install that mutates the installed CLI.
76
+ - The loop's artifact-level distributable smoke ([`skills/tasks/loop-shared.md`](skills/tasks/loop-shared.md) §O.2b) — `npm pack`, temp-prefix global install, and running the shipped binary.
77
+ - Deployment scripts ([`deploy/`](deploy/README.md)) — `sudo`, systemd service control, DB migrations/restores, production dependency installs.
78
+
79
+ Running any of the above against an untrusted checkout executes repo-authored code with real side effects. A trust review's job is to read and reason about these paths, never to run them.
80
+
71
81
  ## Deeper docs
72
82
 
73
83
  | File | One-line purpose |
package/CHANGELOG.md CHANGED
@@ -11,6 +11,46 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [v2.3.0] - 2026-06-27
15
+
16
+ Security-hardening release remediating issues #74, #72, and #75 (reported by the
17
+ Stafficy agent). All changes are backward compatible — defaults reproduce prior
18
+ behavior.
19
+
20
+ ### Added
21
+ - **Proxy-aware rate limiting** (#75). New `TRUST_PROXY` env var (default **off**;
22
+ accepts `true`, an integer hop count, or a comma-separated IP/CIDR allowlist)
23
+ wired into the Fastify factory so `request.ip` derives from `X-Forwarded-For`
24
+ only when a proxy is trusted. The rate limiter gains an explicit `keyGenerator`
25
+ that keys on the authenticated principal when present, else `request.ip`.
26
+ - **Per-route auth/device rate limits** (#75). `/auth/login`, `/auth/callback`,
27
+ `/auth/device/code`, and `/auth/device/verify` get a tighter budget
28
+ (`RATE_LIMIT_AUTH_MAX`, default 10); `/auth/device/token` gets a looser one for
29
+ CLI polling (`RATE_LIMIT_DEVICE_TOKEN_MAX`, default 30). `RATE_LIMIT_MAX` and
30
+ `RATE_LIMIT_TIME_WINDOW` are now part of the validated `src/config/env.ts`
31
+ schema instead of raw `process.env` reads.
32
+
33
+ ### Security
34
+ - **Slack mrkdwn injection** (#74). User-controlled task/project/comment text
35
+ (titles, descriptions, names, comment author + body, assignee, tags) is now
36
+ escaped via a new `escapeSlackMrkdwn` helper before being placed in Slack
37
+ mrkdwn — including the broadcast notification path. Prevents injected Slack
38
+ mentions (`<!channel>`) and spoofed links from appearing in trusted-looking
39
+ notifications. System-owned fields (ids, status/priority enums) are unaffected.
40
+ - **`triggers.yaml` trust boundary enforced** (#72, wft-router). The router now
41
+ rejects its `triggers.yaml` at startup if it is group/other-accessible
42
+ (`mode & 0o077`) or, on POSIX, not owned by the router user — the path is
43
+ realpath-resolved so a benign symlink to an attacker-writable target is also
44
+ rejected. Closes the gap where `docs/event-router-design.md` documented `0600`
45
+ enforcement that did not exist. Windows skips the check (documented deployment
46
+ requirement). Since `triggers.yaml` can run `shell_exec` and arbitrary webhook
47
+ egress, this restores the documented edit-equals-code-execution trust boundary.
48
+ - **Rate-limit hardening for auth routes** (#75). Sensitive auth/device endpoints
49
+ no longer share the broad global budget, narrowing brute-force and
50
+ user-code-enumeration surface; proxy-aware keying prevents all clients behind a
51
+ reverse proxy from collapsing into one bucket (accidental DoS / uneven
52
+ protection).
53
+
14
54
  ## [v2.2.0] - 2026-06-11
15
55
 
16
56
  ### Added
package/SECURITY.md CHANGED
@@ -14,11 +14,11 @@ security updates. Older tags are provided as-is.
14
14
  | Version | Supported |
15
15
  | ----------------- | ------------------ |
16
16
  | `main` (HEAD) | :white_check_mark: |
17
- | `v2.2.0` (latest) | :white_check_mark: |
18
- | `v1.0` – `v2.0.6` | :x: |
17
+ | `v2.3.0` (latest) | :white_check_mark: |
18
+ | `v1.0` – `v2.2.0` | :x: |
19
19
 
20
20
  "Latest" tracks whichever tag is most recent on GitHub; at the time of
21
- writing that is `v2.2.0`. If you are reading this on an older checkout,
21
+ writing that is `v2.3.0`. If you are reading this on an older checkout,
22
22
  verify the current latest release via
23
23
  `git tag --sort=-creatordate | head -1` or the GitHub Releases page.
24
24
 
@@ -1,8 +1,18 @@
1
1
  import { handleCallback } from '../../../services/oidc-client.js';
2
2
  import { upsertFromOidc } from '../../../services/user-upsert.js';
3
3
  import { toAuthenticatedUser } from '../../plugins/auth/strategies/pat.js';
4
+ import { config } from '../../../config/env.js';
4
5
  const callbackRoute = async (fastify, opts) => {
5
- fastify.get('/callback', { config: { skipAuth: true } }, async (request, reply) => {
6
+ // Issue #75 tighter per-route rate limit (auth surface hardening).
7
+ fastify.get('/callback', {
8
+ config: {
9
+ skipAuth: true,
10
+ rateLimit: {
11
+ max: config.RATE_LIMIT_AUTH_MAX,
12
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
13
+ },
14
+ },
15
+ }, async (request, reply) => {
6
16
  const handshake = request.session.get('oidc.handshake');
7
17
  if (!handshake) {
8
18
  request.log.error({ requestId: request.id, peerIp: request.ip }, 'oidc.handshake_missing');
@@ -42,6 +42,16 @@ export interface DeviceCodeRouteOptions {
42
42
  * one OIDC_CLIENT_ID; we reject anything else).
43
43
  */
44
44
  expectedClientId: string;
45
+ /**
46
+ * Issue #68 (finding 2) — optional allowlist of hostnames the per-request
47
+ * verification origin may be built from (sourced from
48
+ * `env.DEVICE_FLOW_TRUSTED_HOSTS`). When non-empty, a request whose
49
+ * `Host` / `X-Forwarded-Host` is not in the list is ignored and the
50
+ * verification URI falls back to the configured {@link origin}. When
51
+ * empty/undefined (default) every Host is honored — backward compatible.
52
+ * Hostnames only (no port); the resolver strips any `:port` before matching.
53
+ */
54
+ trustedHosts?: readonly string[];
45
55
  }
46
56
  /**
47
57
  * Resolve the origin (`scheme://host[:port]`) the CLIENT used to reach this
@@ -58,10 +68,16 @@ export interface DeviceCodeRouteOptions {
58
68
  * is returned ONLY to the same client that sent the request, so a spoofed Host
59
69
  * merely misdirects the spoofer. Falls back to `fallback` (the configured
60
70
  * origin) when no Host header is present at all.
71
+ *
72
+ * Issue #68 (finding 2) — an operator who wants to pin the trust boundary may
73
+ * pass `trustedHosts` (from `env.DEVICE_FLOW_TRUSTED_HOSTS`). When that list is
74
+ * non-empty, a Host whose hostname is NOT on it is refused and we fall back to
75
+ * the configured `fallback` origin rather than echoing an arbitrary header.
76
+ * When the list is empty/omitted the behavior is unchanged (every Host honored).
61
77
  */
62
78
  export declare function resolveVerificationOrigin(request: {
63
79
  headers: Record<string, string | string[] | undefined>;
64
80
  protocol?: string;
65
- }, fallback: string): string;
81
+ }, fallback: string, trustedHosts?: readonly string[]): string;
66
82
  declare const deviceCodeRoute: FastifyPluginAsync<DeviceCodeRouteOptions>;
67
83
  export default deviceCodeRoute;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { createSession } from '../../../services/device-flow-store.js';
3
+ import { config } from '../../../config/env.js';
3
4
  /** First value of a possibly comma-joined / array-valued HTTP header. */
4
5
  function firstHeaderValue(v) {
5
6
  const raw = Array.isArray(v) ? v[0] : v;
@@ -23,12 +24,25 @@ function firstHeaderValue(v) {
23
24
  * is returned ONLY to the same client that sent the request, so a spoofed Host
24
25
  * merely misdirects the spoofer. Falls back to `fallback` (the configured
25
26
  * origin) when no Host header is present at all.
27
+ *
28
+ * Issue #68 (finding 2) — an operator who wants to pin the trust boundary may
29
+ * pass `trustedHosts` (from `env.DEVICE_FLOW_TRUSTED_HOSTS`). When that list is
30
+ * non-empty, a Host whose hostname is NOT on it is refused and we fall back to
31
+ * the configured `fallback` origin rather than echoing an arbitrary header.
32
+ * When the list is empty/omitted the behavior is unchanged (every Host honored).
26
33
  */
27
- export function resolveVerificationOrigin(request, fallback) {
34
+ export function resolveVerificationOrigin(request, fallback, trustedHosts = []) {
28
35
  const host = firstHeaderValue(request.headers['x-forwarded-host']) ??
29
36
  firstHeaderValue(request.headers['host']);
30
37
  if (!host)
31
38
  return fallback;
39
+ // When an allowlist is configured, the Host's hostname (sans :port) must be
40
+ // on it; otherwise refuse the header and use the configured origin.
41
+ if (trustedHosts.length > 0) {
42
+ const hostname = (host.split(':')[0] ?? host).toLowerCase();
43
+ if (!trustedHosts.includes(hostname))
44
+ return fallback;
45
+ }
32
46
  const scheme = firstHeaderValue(request.headers['x-forwarded-proto']) ??
33
47
  (request.protocol && request.protocol.length > 0 ? request.protocol : 'http');
34
48
  return `${scheme}://${host}`;
@@ -44,7 +58,16 @@ const BodySchema = z.object({
44
58
  scope: z.string().optional(),
45
59
  });
46
60
  const deviceCodeRoute = async (fastify, opts) => {
47
- fastify.post('/auth/device/code', { config: { skipAuth: true } }, async (request, reply) => {
61
+ // Issue #75 tighter per-route rate limit (auth surface hardening).
62
+ fastify.post('/auth/device/code', {
63
+ config: {
64
+ skipAuth: true,
65
+ rateLimit: {
66
+ max: config.RATE_LIMIT_AUTH_MAX,
67
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
68
+ },
69
+ },
70
+ }, async (request, reply) => {
48
71
  // Manual Zod parse so the error envelope matches RFC 8628 verbatim
49
72
  // (`{error: 'invalid_request'}`) — Fastify's default 400 carries the
50
73
  // `statusCode/error/message` triplet which is not what RFC 8628 wants.
@@ -73,7 +96,7 @@ const deviceCodeRoute = async (fastify, opts) => {
73
96
  // #834: build the verification URL from the address the CLIENT connected to
74
97
  // (request Host / X-Forwarded-*), not the static configured origin, so a
75
98
  // remote/LAN client gets a URL it can actually open instead of localhost.
76
- const origin = resolveVerificationOrigin(request, opts.origin);
99
+ const origin = resolveVerificationOrigin(request, opts.origin, opts.trustedHosts ?? []);
77
100
  return reply.code(200).send({
78
101
  device_code: session.deviceCode,
79
102
  user_code: session.userCode,
@@ -3,6 +3,7 @@ import { renderDevicePage, renderDeviceApprovedPage } from '../../../web/pages/d
3
3
  import { getOrCreateCsrfToken, verifyCsrfToken } from './csrf.js';
4
4
  import { requireUser } from '../../plugins/auth.js';
5
5
  import { generateToken } from '../../../services/pat-hash.js';
6
+ import { config } from '../../../config/env.js';
6
7
  /**
7
8
  * Strict alphabet check for `?user_code=`. MUST match the alphabet used by
8
9
  * device-flow-store's generator: 31 confusable-free uppercase chars,
@@ -70,7 +71,16 @@ const deviceHtmlRoute = async (fastify, opts) => {
70
71
  // 4. Success → approve() then renderDeviceApprovedPage(). Logger emits
71
72
  // `event: device_flow_approved` with the userId — user_code is
72
73
  // DELIBERATELY OMITTED from the log payload (Threat T-30-02-06).
73
- fastify.post('/auth/device/verify', { config: { sessionOnly: true } }, async (request, reply) => {
74
+ // Issue #75 tighter per-route rate limit (auth surface hardening).
75
+ fastify.post('/auth/device/verify', {
76
+ config: {
77
+ sessionOnly: true,
78
+ rateLimit: {
79
+ max: config.RATE_LIMIT_AUTH_MAX,
80
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
81
+ },
82
+ },
83
+ }, async (request, reply) => {
74
84
  const body = (request.body ?? {});
75
85
  // 1. CSRF gate.
76
86
  if (!verifyCsrfToken(request, body._csrf)) {
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { findByDeviceCode, remove } from '../../../services/device-flow-store.js';
3
3
  import { toAuthenticatedUser } from '../../plugins/auth/strategies/pat.js';
4
+ import { config } from '../../../config/env.js';
4
5
  /** RFC 8628 §3.4 grant_type literal. */
5
6
  const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
6
7
  /**
@@ -20,7 +21,19 @@ const deviceTokenRoute = async (fastify, opts) => {
20
21
  if (!fastify.hasDecorator('userRepository')) {
21
22
  throw new Error('deviceTokenRoute requires userRepository to be decorated before registration');
22
23
  }
23
- fastify.post('/auth/device/token', { config: { skipAuth: true } }, async (request, reply) => {
24
+ // Issue #75 the CLI polls this endpoint every `interval` seconds, so it
25
+ // gets a looser per-route budget than the other auth routes (still well
26
+ // below the global max). Env-tunable via RATE_LIMIT_DEVICE_TOKEN_MAX /
27
+ // RATE_LIMIT_AUTH_TIME_WINDOW.
28
+ fastify.post('/auth/device/token', {
29
+ config: {
30
+ skipAuth: true,
31
+ rateLimit: {
32
+ max: config.RATE_LIMIT_DEVICE_TOKEN_MAX,
33
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
34
+ },
35
+ },
36
+ }, async (request, reply) => {
24
37
  // request.body is either the parsed JSON object OR the formbody plugin's
25
38
  // parsed URLSearchParams shape — both surface as plain objects to Zod.
26
39
  const parsed = BodySchema.safeParse(request.body);
@@ -1,4 +1,5 @@
1
1
  import { buildAuthorizationUrl, calculatePKCECodeChallenge, randomNonce, randomPKCECodeVerifier, randomState, } from '../../../services/oidc-client.js';
2
+ import { config } from '../../../config/env.js';
2
3
  /**
3
4
  * Validates `?next=<path>` for open-redirect safety. Pattern: a SINGLE
4
5
  * leading slash followed by ANY character that is NOT another slash AND
@@ -33,7 +34,18 @@ const NEXT_PATH_RE = /^\/[^/\\]/;
33
34
  */
34
35
  const DEVICE_NEXT_RE = /^\/auth\/device(\?user_code=[A-HJ-KM-NP-Z2-9]{8})?$/;
35
36
  const loginRoute = async (fastify, opts) => {
36
- fastify.get('/login', { config: { skipAuth: true } }, async (request, reply) => {
37
+ // Issue #75 tighter per-route rate limit than the global budget
38
+ // (brute-force / DoS hardening on the sensitive auth surface). Env-tunable
39
+ // via RATE_LIMIT_AUTH_MAX / RATE_LIMIT_AUTH_TIME_WINDOW.
40
+ fastify.get('/login', {
41
+ config: {
42
+ skipAuth: true,
43
+ rateLimit: {
44
+ max: config.RATE_LIMIT_AUTH_MAX,
45
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
46
+ },
47
+ },
48
+ }, async (request, reply) => {
37
49
  // Already signed in — short-circuit to /me (the documented home
38
50
  // page for authenticated users). Skip the IdP roundtrip entirely.
39
51
  if (request.session.get('user')) {
@@ -57,6 +57,14 @@ export async function createServer(options) {
57
57
  const app = await createApp(options?.dbPath);
58
58
  // Create Fastify instance with logger
59
59
  const server = Fastify({
60
+ // Issue #75 — proxy-aware client identity. `trustProxy` makes
61
+ // `request.ip` resolve from `X-Forwarded-For` (consistent with the
62
+ // device-flow origin trust in §3a). DEFAULT OFF (config.TRUST_PROXY ===
63
+ // false) so a spoofed X-Forwarded-For cannot move a client's rate-limit
64
+ // bucket on the loopback-bind default; operators behind a reverse proxy
65
+ // opt in via TRUST_PROXY. Accepts boolean | number (hop count) | string[]
66
+ // (IP/CIDR allowlist) — see src/config/env.ts.
67
+ trustProxy: config.TRUST_PROXY,
60
68
  // Timeout configurations to prevent hung requests
61
69
  connectionTimeout: config.CONNECTION_TIMEOUT, // Socket inactivity timeout (2 min)
62
70
  requestTimeout: config.REQUEST_TIMEOUT, // Maximum time for entire request (1 min)
@@ -246,9 +254,30 @@ export async function createServer(options) {
246
254
  // avoid disrupting the existing test suite, which exercises many
247
255
  // server.inject calls from 127.0.0.1; operators tune via env.
248
256
  await server.register(rateLimit, {
249
- max: Number(process.env['RATE_LIMIT_MAX'] ?? 1000),
250
- timeWindow: process.env['RATE_LIMIT_TIME_WINDOW'] ?? '1 minute',
257
+ // Issue #75 — global budget now sourced from the validated config
258
+ // (src/config/env.ts), not raw process.env. Defaults reproduce the
259
+ // prior effective behavior exactly: 1000 requests / 1 minute.
260
+ max: config.RATE_LIMIT_MAX,
261
+ timeWindow: config.RATE_LIMIT_TIME_WINDOW,
251
262
  allowList: (req) => req.url === '/health' || req.url.startsWith('/health/'),
263
+ // Issue #75 — proxy-aware keying. Prefer the authenticated principal
264
+ // (PAT token id, else user id) so a single proxy IP does not collapse
265
+ // every authenticated client into one bucket; fall back to
266
+ // `request.ip`, which Fastify resolves from X-Forwarded-For ONLY when
267
+ // `trustProxy` is set (default OFF). The KEY GUARANTEE: with trustProxy
268
+ // OFF a spoofed X-Forwarded-For cannot change `request.ip`, so it
269
+ // cannot move the bucket. The rate-limit plugin is registered above the
270
+ // auth scope, so `request.tokenId` / `request.user` may be undefined on
271
+ // routes outside that scope — guard both.
272
+ keyGenerator: (req) => {
273
+ const tokenId = req.tokenId;
274
+ if (typeof tokenId === 'number')
275
+ return `tok:${tokenId}`;
276
+ const user = req.user;
277
+ if (user && typeof user.id === 'number')
278
+ return `usr:${user.id}`;
279
+ return `ip:${req.ip}`;
280
+ },
252
281
  // The error returned here is thrown by @fastify/rate-limit; the project's
253
282
  // custom errorHandler reads `statusCode` and `code` to shape the JSON
254
283
  // response. We attach both so the response surfaces as
@@ -431,6 +460,9 @@ export async function createServer(options) {
431
460
  await scope.register(deviceCodeRoute, {
432
461
  origin,
433
462
  expectedClientId: deviceClientId,
463
+ // Issue #68 (finding 2) — optional operator allowlist pinning which
464
+ // hostnames the per-request verification origin may be derived from.
465
+ trustedHosts: config.DEVICE_FLOW_TRUSTED_HOSTS,
434
466
  });
435
467
  await scope.register(deviceTokenRoute, { expectedClientId: deviceClientId });
436
468
  await scope.register(deviceHtmlRoute, { origin });
@@ -20,7 +20,12 @@
20
20
  */
21
21
  import { spawn } from 'node:child_process';
22
22
  /**
23
- * WR-03 (Phase 30 review) — validate the URL shape BEFORE handing it to
23
+ * WR-03 (Phase 30 review) — STATUS: MITIGATED. This function IS the active
24
+ * mitigation; `openBrowser` calls it before any spawn. The injection shape
25
+ * described below is historical context explaining WHY the gate exists, not an
26
+ * open vulnerability.
27
+ *
28
+ * Validate the URL shape BEFORE handing it to
24
29
  * a child process. The Windows leg invokes `cmd.exe /c start "" <url>`;
25
30
  * even with `shell: false`, libuv's WinAPI quoting of the args array can
26
31
  * be perturbed by a URL containing embedded double quotes or trailing
@@ -69,9 +74,17 @@ function isSafeBrowserUrl(url) {
69
74
  return true;
70
75
  }
71
76
  export function openBrowser(url) {
72
- // WR-03 (Phase 30 review) — validate the URL BEFORE selecting the
73
- // platform spawn args. A malformed/suspicious URL false so the caller
74
- // falls back to printing the URL for the user to paste manually.
77
+ // WR-03 (Phase 30 review) — ACTIVE injection gate. Validate the URL BEFORE
78
+ // selecting the platform spawn args, so EVERY platform branch below (incl.
79
+ // the Windows `cmd /c start` path) only ever receives an http(s) URL that
80
+ // round-trips through the URL parser unchanged. A malformed/suspicious URL →
81
+ // false here, before any spawn, so the caller falls back to printing the URL
82
+ // for the user to paste manually.
83
+ //
84
+ // NOTE for reviewers: the historical `verification_uri_complete` cmd-injection
85
+ // shape described in isSafeBrowserUrl's doc block is MITIGATED by this gate —
86
+ // it is documented for context, not an open hole. See the regression test
87
+ // `__tests__/browser-open.test.ts` ("Windows cmd-injection attempt").
75
88
  if (!isSafeBrowserUrl(url)) {
76
89
  return false;
77
90
  }
@@ -83,6 +96,10 @@ export function openBrowser(url) {
83
96
  args = [url];
84
97
  break;
85
98
  case 'win32':
99
+ // SAFE: `url` already passed the WR-03 isSafeBrowserUrl gate above, so it
100
+ // is a parser-canonical http(s) URL with no embedded quotes/backslashes
101
+ // that could escape libuv's WinAPI arg quoting — `cmd /c start` cannot be
102
+ // tricked into running metacharacters here. (We still pass shell:false.)
86
103
  // The empty `""` is the title argument for `cmd /c start` — without it,
87
104
  // `start` interprets the first quoted arg as the window title and never
88
105
  // opens the URL.
@@ -75,11 +75,18 @@ export declare const configSchema: z.ZodObject<{
75
75
  SSE_MAX_CONNECTIONS_PER_KEY: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
76
76
  SSE_MAX_CONNECTIONS_PER_IP: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
77
77
  SSE_MAX_CONNECTIONS: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
78
+ RATE_LIMIT_MAX: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
79
+ RATE_LIMIT_TIME_WINDOW: z.ZodDefault<z.ZodString>;
80
+ RATE_LIMIT_AUTH_MAX: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
81
+ RATE_LIMIT_AUTH_TIME_WINDOW: z.ZodDefault<z.ZodString>;
82
+ RATE_LIMIT_DEVICE_TOKEN_MAX: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
83
+ TRUST_PROXY: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<number | boolean | string[], string | undefined>>;
78
84
  OIDC_ISSUER_URL: z.ZodOptional<z.ZodString>;
79
85
  OIDC_CLIENT_ID: z.ZodOptional<z.ZodString>;
80
86
  OIDC_CLIENT_SECRET: z.ZodOptional<z.ZodString>;
81
87
  OIDC_REDIRECT_URI: z.ZodOptional<z.ZodString>;
82
88
  OIDC_DEVICE_CLIENT_ID: z.ZodDefault<z.ZodString>;
89
+ DEVICE_FLOW_TRUSTED_HOSTS: z.ZodPipe<z.ZodOptional<z.ZodString>, z.ZodTransform<string[], string | undefined>>;
83
90
  OIDC_POST_LOGOUT_REDIRECT_URI: z.ZodOptional<z.ZodString>;
84
91
  OIDC_SCOPES: z.ZodDefault<z.ZodString>;
85
92
  OIDC_DISCOVERY_MAX_ATTEMPTS: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
@@ -100,6 +100,49 @@ export const configSchema = z
100
100
  SSE_MAX_CONNECTIONS_PER_KEY: z.string().min(1).default('4').transform(Number),
101
101
  SSE_MAX_CONNECTIONS_PER_IP: z.string().min(1).default('8').transform(Number),
102
102
  SSE_MAX_CONNECTIONS: z.string().min(1).default('200').transform(Number),
103
+ // Issue #75 — global + per-route rate-limit budgets. RATE_LIMIT_MAX /
104
+ // RATE_LIMIT_TIME_WINDOW back the global @fastify/rate-limit registration
105
+ // (moved here from raw process.env reads in server.ts). Defaults reproduce
106
+ // the prior effective behavior exactly: 1000 requests / 1 minute, no proxy
107
+ // trust. The AUTH_* / DEVICE_TOKEN_* knobs tighten the five sensitive
108
+ // auth/device routes below the global budget (per-route config.rateLimit).
109
+ RATE_LIMIT_MAX: z.string().min(1).default('1000').transform(Number),
110
+ RATE_LIMIT_TIME_WINDOW: z.string().min(1).default('1 minute'),
111
+ // Tighter budget for the sensitive auth/device routes (login, callback,
112
+ // device/code, device/verify). Brute-force + DoS hardening.
113
+ RATE_LIMIT_AUTH_MAX: z.string().min(1).default('10').transform(Number),
114
+ RATE_LIMIT_AUTH_TIME_WINDOW: z.string().min(1).default('1 minute'),
115
+ // device/token is polled by the CLI every `interval` seconds, so it gets
116
+ // a looser budget than the other auth routes (still well below global).
117
+ RATE_LIMIT_DEVICE_TOKEN_MAX: z.string().min(1).default('30').transform(Number),
118
+ // Issue #75 — proxy-aware client identity for rate limiting. Controls
119
+ // Fastify's `trustProxy` factory option, which is what makes
120
+ // `request.ip` resolve from `X-Forwarded-For`. DEFAULT OFF (false) so the
121
+ // loopback-bind default never trusts forwarded headers — a spoofed
122
+ // X-Forwarded-For cannot move a client's rate-limit bucket. Operators
123
+ // behind a reverse proxy (the documented deployment) opt in. Accepted:
124
+ // - unset / 'false' → false (do NOT trust forwarded headers)
125
+ // - 'true' → true (trust all — proxy is the only hop)
126
+ // - integer hop count ('1') → number (trust N proxy hops)
127
+ // - 'ip,cidr,…' → string[] (trust only these proxy IPs/CIDRs)
128
+ TRUST_PROXY: z
129
+ .string()
130
+ .optional()
131
+ .transform((raw) => {
132
+ const s = (raw ?? '').trim();
133
+ if (s === '' || s.toLowerCase() === 'false')
134
+ return false;
135
+ if (s.toLowerCase() === 'true')
136
+ return true;
137
+ // Pure integer → hop count. (Reject negatives/decimals → fall through.)
138
+ if (/^\d+$/.test(s))
139
+ return Number(s);
140
+ // Otherwise treat as a comma-separated IP/CIDR allowlist.
141
+ return s
142
+ .split(',')
143
+ .map((p) => p.trim())
144
+ .filter((p) => p.length > 0);
145
+ }),
103
146
  // Phase 29: OIDC browser flow + session cookie configuration.
104
147
  // All four OIDC_* vars are all-or-nothing (see refine below). When unset,
105
148
  // OIDC routes return 501 and the session strategy returns null — PAT +
@@ -118,6 +161,26 @@ export const configSchema = z
118
161
  // on any server backed by a real IdP. Defaulted so it works with the
119
162
  // stock CLI and is NOT part of the all-or-nothing OIDC group.
120
163
  OIDC_DEVICE_CLIENT_ID: z.string().min(1).default('wft-cli'),
164
+ // Issue #68 (finding 2) — optional allowlist of hostnames the device-flow
165
+ // verification origin may be built from. The `verification_uri` the CLI
166
+ // prints is derived per-request from the `Host` / `X-Forwarded-Host` header
167
+ // (see resolveVerificationOrigin) so a LAN/remote client gets a routable
168
+ // URL instead of `localhost`. That is not a host-header-INJECTION vector
169
+ // (the URL is returned only to the requesting client), but an operator who
170
+ // wants to pin the trust boundary can set this to a comma-separated list of
171
+ // hostnames (`host` or `host:port` accepted; the port is ignored in the
172
+ // match). When set, a request whose Host is NOT in the list falls back to
173
+ // the configured origin instead of being echoed. When UNSET (default),
174
+ // behavior is unchanged — every Host is honored (backward compatible).
175
+ DEVICE_FLOW_TRUSTED_HOSTS: z
176
+ .string()
177
+ .optional()
178
+ .transform((s) => (s ?? '')
179
+ .split(',')
180
+ .map((h) => h.trim().toLowerCase())
181
+ // Normalize `host:port` → `host`; the allowlist matches on hostname.
182
+ .map((h) => (h.includes(':') ? (h.split(':')[0] ?? h) : h))
183
+ .filter((h) => h.length > 0)),
121
184
  // WR-03 fix — `post_logout_redirect_uri` for RP-initiated logout.
122
185
  // Optional: when absent, the wiring at src/api/server.ts derives a
123
186
  // default from OIDC_REDIRECT_URI's origin (+ `/auth/login`). Sourcing
@@ -696,6 +696,14 @@ For MCP tools **NEWLY ADDED during this run**, exercise them through the **remot
696
696
 
697
697
  ### (2b) Artifact-level distributable smoke — the SHIPPED artifact, exercised from OUTSIDE the repo
698
698
 
699
+ > ⚠️ **Trusted-operator / trusted-repo operation.** The smoke below packs the
700
+ > repo (`npm pack`), installs the tarball into a temp prefix
701
+ > (`npm i -g --prefix …`), and **executes the shipped binary**. These steps
702
+ > mutate global state and run repo-authored code. Run them ONLY against a
703
+ > checkout you trust and operate. A static-trust or security review of an
704
+ > **untrusted** checkout must **read, not run**, this section — never pack,
705
+ > install, or execute the artifact while evaluating an unknown repo.
706
+
699
707
  **Why this exists (load-bearing).** Per-task verifiers grade against the SOURCE tree, so a capability can be 100% PASS-verified in-repo while the **shipped artifact** (the packed tarball / globally-installed bin) is broken — e.g. an asset resolved from a path that exists in source but is not in the published `files`, a postinstall that references an unshipped file, or a CLI option dropped by a framework bug. These failures are cwd- and packaging-sensitive: they pass in-repo and fail only once installed and run from elsewhere. This smoke closes that gap by exercising the real artifact from a cwd OUTSIDE the repo.
700
708
 
701
709
  **Trigger (unconditional when the repo ships a distributable).** This smoke runs whenever the target repo ships a distributable — it is NOT gated on a particular task having existed in the run. Detect a distributable generically from `package.json`: it declares a `bin` and/or a `files` allow-list and/or a `prepublishOnly` script, OR it defines a `smoke:global`-style global-install smoke script. If none of these signals is present, the repo ships no distributable and this audit is N/A (skip, not RED).
@@ -6,6 +6,14 @@ disable-model-invocation: false
6
6
 
7
7
  # Update Wood Fired Tasks
8
8
 
9
+ > ⚠️ **Trusted-operator / trusted-repo operation.** This skill runs
10
+ > `tasks self-update`, which shells out to a global npm install
11
+ > (`npm i -g wood-fired-tasks@latest`) and mutates the host's globally
12
+ > installed CLI. Run it ONLY against a checkout you trust and operate. A
13
+ > static-trust or security review of an **untrusted** checkout must **read,
14
+ > not run**, this flow — never execute self-update, install, or package
15
+ > flows while evaluating an unknown repo.
16
+
9
17
  Updates the installed `tasks` CLI to the latest published version by running the
10
18
  built-in `tasks self-update` command. This is the action target of the
11
19
  status-line update notification (`⬆ /tasks:update`).
@@ -1,6 +1,7 @@
1
1
  import { NotFoundError, ValidationError, BusinessError } from '../../services/errors.js';
2
2
  import { formatTaskList, formatTaskDetail } from '../task-formatter.js';
3
3
  import { formatProjectList, formatProjectDetail } from '../formatters/project-formatter.js';
4
+ import { escapeSlackMrkdwn } from '../mrkdwn.js';
4
5
  import { ALLOWED_EVENT_TYPES, isAllowedEventType } from '../../events/types.js';
5
6
  /**
6
7
  * Hard cap on subscription rows (project_id x event_type) per Slack channel.
@@ -243,7 +244,10 @@ async function handleShow(respond, services, args) {
243
244
  for (const comment of displayComments) {
244
245
  allBlocks.push({
245
246
  type: 'section',
246
- text: { type: 'mrkdwn', text: `*${comment.author}*: ${comment.content}` },
247
+ text: {
248
+ type: 'mrkdwn',
249
+ text: `*${escapeSlackMrkdwn(comment.author)}*: ${escapeSlackMrkdwn(comment.content)}`,
250
+ },
247
251
  });
248
252
  }
249
253
  if (comments.length > 5) {
@@ -302,7 +306,7 @@ async function handleCreate(respond, services, identityCache, command, args, sla
302
306
  description: flags['description'] || null,
303
307
  });
304
308
  const blocks = formatTaskDetail(task);
305
- await respondBlocks(respond, blocks, `Task created: ${task.title}`);
309
+ await respondBlocks(respond, blocks, `Task created: ${escapeSlackMrkdwn(task.title)}`);
306
310
  }
307
311
  /**
308
312
  * handleUpdate — /tasks update <id> [--status <s>] [--title <t>] [--priority <p>] [--assignee <a>] [--due <d>] [--description <d>]
@@ -443,7 +447,7 @@ async function handleProjectCreate(respond, services, args) {
443
447
  description: flags['description'] || null,
444
448
  });
445
449
  const blocks = formatProjectDetail(project);
446
- await respondBlocks(respond, blocks, `Project created: ${project.name}`);
450
+ await respondBlocks(respond, blocks, `Project created: ${escapeSlackMrkdwn(project.name)}`);
447
451
  }
448
452
  /**
449
453
  * handleProjectUpdate — /tasks project-update <id> [--name <name>] [--description <desc>]
@@ -612,7 +616,7 @@ async function handleCommentList(respond, services, args) {
612
616
  type: 'section',
613
617
  text: {
614
618
  type: 'mrkdwn',
615
- text: `*${comment.author}* (${comment.created_at})\n${comment.content}`,
619
+ text: `*${escapeSlackMrkdwn(comment.author)}* (${comment.created_at})\n${escapeSlackMrkdwn(comment.content)}`,
616
620
  },
617
621
  });
618
622
  }
@@ -673,7 +677,7 @@ async function handleSubtaskCreate(respond, services, identityCache, command, ar
673
677
  created_by_user_id: actorUserId,
674
678
  });
675
679
  const blocks = formatTaskDetail(task);
676
- await respondBlocks(respond, blocks, `Subtask created: ${task.title}`);
680
+ await respondBlocks(respond, blocks, `Subtask created: ${escapeSlackMrkdwn(task.title)}`);
677
681
  }
678
682
  /**
679
683
  * handleSubtaskList — /tasks subtask-list <parent_id>
@@ -798,10 +802,10 @@ async function handleSubscribe(respond, services, subscriptionRepo, command, arg
798
802
  type: 'section',
799
803
  text: {
800
804
  type: 'mrkdwn',
801
- text: `:bell: Subscribed this channel to *${project.name}* events: ${eventTypes.map((e) => '`' + e + '`').join(', ')}`,
805
+ text: `:bell: Subscribed this channel to *${escapeSlackMrkdwn(project.name)}* events: ${eventTypes.map((e) => '`' + e + '`').join(', ')}`,
802
806
  },
803
807
  },
804
- ], `Subscribed to ${project.name} events`);
808
+ ], `Subscribed to ${escapeSlackMrkdwn(project.name)} events`);
805
809
  }
806
810
  /**
807
811
  * handleUnsubscribe — /tasks unsubscribe [--project <id>]
@@ -1,3 +1,4 @@
1
+ import { escapeSlackMrkdwn } from '../mrkdwn.js';
1
2
  // ---------------------------------------------------------------------------
2
3
  // Helpers
3
4
  // ---------------------------------------------------------------------------
@@ -38,13 +39,13 @@ export function formatProjectList(projects) {
38
39
  if (project === undefined)
39
40
  continue;
40
41
  const descPreview = project.description
41
- ? truncate(project.description, 100)
42
+ ? escapeSlackMrkdwn(truncate(project.description, 100))
42
43
  : '_no description_';
43
44
  const section = {
44
45
  type: 'section',
45
46
  text: {
46
47
  type: 'mrkdwn',
47
- text: `*#${project.id} ${project.name}*\n${descPreview}`,
48
+ text: `*#${project.id} ${escapeSlackMrkdwn(project.name)}*\n${descPreview}`,
48
49
  },
49
50
  };
50
51
  blocks.push(section);
@@ -73,11 +74,13 @@ export function formatProjectDetail(project) {
73
74
  type: 'header',
74
75
  text: {
75
76
  type: 'plain_text',
76
- text: nameText,
77
+ text: escapeSlackMrkdwn(nameText),
77
78
  emoji: true,
78
79
  },
79
80
  };
80
- const descText = project.description ? truncate(project.description, 200) : '_none_';
81
+ const descText = project.description
82
+ ? escapeSlackMrkdwn(truncate(project.description, 200))
83
+ : '_none_';
81
84
  const fieldsSection = {
82
85
  type: 'section',
83
86
  fields: [
@@ -0,0 +1 @@
1
+ export declare function escapeSlackMrkdwn(value: unknown): string;