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 +10 -0
- package/CHANGELOG.md +40 -0
- package/SECURITY.md +3 -3
- package/dist/api/routes/auth/callback.js +11 -1
- package/dist/api/routes/auth/device-code.d.ts +17 -1
- package/dist/api/routes/auth/device-code.js +26 -3
- package/dist/api/routes/auth/device-html.js +11 -1
- package/dist/api/routes/auth/device-token.js +14 -1
- package/dist/api/routes/auth/login.js +13 -1
- package/dist/api/server.js +34 -2
- package/dist/cli/auth/browser-open.js +21 -4
- package/dist/config/env.d.ts +7 -0
- package/dist/config/env.js +63 -0
- package/dist/skills/tasks/loop-shared.md +8 -0
- package/dist/skills/tasks/update.md +8 -0
- package/dist/slack/commands/tasks-command.js +11 -7
- package/dist/slack/formatters/project-formatter.js +7 -4
- package/dist/slack/mrkdwn.d.ts +1 -0
- package/dist/slack/mrkdwn.js +26 -0
- package/dist/slack/task-formatter.js +12 -11
- package/docs/INTERFACES.md +8 -1
- package/docs/SETUP.md +39 -27
- package/package.json +5 -5
- package/packages/wft-router/README.md +2 -0
- package/packages/wft-router/dist/config/triggers-schema.d.ts +4 -0
- package/packages/wft-router/dist/config/triggers-schema.js +72 -0
- package/packages/wft-router/dist/daemon.d.ts +52 -0
- package/packages/wft-router/dist/daemon.js +74 -1
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.
|
|
18
|
-
| `v1.0` – `v2.0
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')) {
|
package/dist/api/server.js
CHANGED
|
@@ -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
|
-
|
|
250
|
-
|
|
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) —
|
|
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) —
|
|
73
|
-
// platform spawn args
|
|
74
|
-
//
|
|
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.
|
package/dist/config/env.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/config/env.js
CHANGED
|
@@ -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: {
|
|
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
|
|
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;
|