@unbrained/pm-web 2026.8.17 → 2026.8.29
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/CHANGELOG.md +28 -0
- package/README.md +34 -4
- package/dist/app.d.ts +25 -1
- package/dist/app.js +69 -9
- package/dist/app.js.map +1 -1
- package/dist/csrf.d.ts +48 -0
- package/dist/csrf.js +124 -0
- package/dist/csrf.js.map +1 -0
- package/dist/health.d.ts +84 -0
- package/dist/health.js +284 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +25 -3
- package/dist/index.js +62 -42
- package/dist/index.js.map +1 -1
- package/dist/rate-limit.d.ts +149 -0
- package/dist/rate-limit.js +194 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/routes/auth.d.ts +16 -0
- package/dist/routes/auth.js +37 -1
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/github.d.ts +19 -0
- package/dist/routes/github.js +49 -4
- package/dist/routes/github.js.map +1 -1
- package/dist/routes/pm.js +28 -38
- package/dist/routes/pm.js.map +1 -1
- package/dist/server.js +33 -2
- package/dist/server.js.map +1 -1
- package/dist/services/mutation-event-watcher.js +2 -1
- package/dist/services/mutation-event-watcher.js.map +1 -1
- package/dist/services/package-catalog.js +2 -2
- package/dist/services/package-catalog.js.map +1 -1
- package/dist/services/pm-runner.d.ts +51 -1
- package/dist/services/pm-runner.js +105 -1
- package/dist/services/pm-runner.js.map +1 -1
- package/manifest.json +3 -5
- package/package.json +24 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Harden workflow publish scanning against YAML and reassignment bypasses ([pm-web-yafj](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-yafj.toon))
|
|
8
|
+
|
|
9
|
+
## 2026.8.29 - 2026-08-29
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Every public API route lacked rate limiting, leaving auth and write endpoints open to abuse and denial of service ([pm-web-8dk0](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-8dk0.toon))
|
|
14
|
+
|
|
15
|
+
## 2026.8.28 - 2026-08-28
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- A failed provenance publish silently falls back to an unattested one ([pm-web-f8hk](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-f8hk.toon))
|
|
20
|
+
- The health endpoint no longer reports ok: true when the database or projects volume is unreachable ([pm-web-u9mh](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-u9mh.toon))
|
|
21
|
+
- Mount the real /healthz handler in pm-web (Greptile P1 on PR \#105) ([pm-web-iocm](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-iocm.toon))
|
|
22
|
+
- The changelog gate stamps an untagged version with the current date, so its verdict flips every midnight with no commit ([pm-web-kcs1](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-kcs1.toon))
|
|
23
|
+
- Certify pm-web complete tracker reads and refresh the package catalog ([pm-web-crdr](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-crdr.toon))
|
|
24
|
+
- Docstring corrections: filters, graph-canvas, crypto, board, pm routes, project-watcher, sse, mutation-event-watcher ([pm-web-oehb](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-oehb.toon))
|
|
25
|
+
- Fix release publish-before-protected-main-push ordering ([pm-web-8l6j](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-8l6j.toon))
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
|
|
29
|
+
- The identity gate deadlocks the one remediation its own failure message prescribes ([pm-web-1ggj](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-1ggj.toon))
|
|
30
|
+
|
|
3
31
|
## 2026.8.17 - 2026-08-17
|
|
4
32
|
|
|
5
33
|
### Fixed
|
package/README.md
CHANGED
|
@@ -118,6 +118,7 @@ pm web doctor --json
|
|
|
118
118
|
| `PM_CLI_BIN` | No | Explicit pm CLI executable path (default: packaged CLI, then `pm` from `PATH`) |
|
|
119
119
|
| `PM_WEB_DB_POOL_MAX` | No | PostgreSQL pool size including one dedicated realtime listener (default: `20`, minimum: `2`) |
|
|
120
120
|
| `NODE_ENV` | No | `production` enables caching |
|
|
121
|
+
| `PM_WEB_TRUST_PROXY` | Behind a proxy | Reverse-proxy hops to trust for the client address (`1` for a single proxy such as Caddy), a comma-separated IP/subnet allowlist, or `false`/`0`. **Defaults to `false`** — a direct deployment must not trust `X-Forwarded-For`, or a caller can rotate that header to draw a fresh rate-limit bucket per request. Set it only when a proxy really does sit in front, otherwise the per-IP limits enforce nothing. |
|
|
121
122
|
| `OLLAMA_BASE_URL` / `OLLAMA_HOST` | No | Local Ollama endpoint for semantic pm search |
|
|
122
123
|
| `PM_OLLAMA_MODEL` | No | Embedding model for new projects, default `qwen3-embedding:0.6b` |
|
|
123
124
|
| `NEO4J_URI` | No | Neo4j Bolt URI for graph sync |
|
|
@@ -149,6 +150,19 @@ within each Node process, so high-throughput installations should run multiple
|
|
|
149
150
|
pm-web replicas behind a shared PostgreSQL realtime bus. Independent replicas
|
|
150
151
|
converge through PostgreSQL notifications and the mutation-event watcher.
|
|
151
152
|
|
|
153
|
+
Whole-project graph fallback, board, local search, iCalendar, and export reads
|
|
154
|
+
use the SDK's high-level `listAllComplete` operation. pm-web accepts those rows
|
|
155
|
+
only after the shared SDK certificate and supplemental 2026.8.21 source,
|
|
156
|
+
omission, output-receipt, and budget checks all agree. The public `/pm/list-all`
|
|
157
|
+
HTTP compatibility route remains deliberately paginated for interactive clients,
|
|
158
|
+
but invokes canonical `list --all` internally; consumers that need the whole
|
|
159
|
+
workspace must use a complete-read endpoint rather than assembling a page as if
|
|
160
|
+
it were the corpus. The standalone server exact-pins pm CLI/SDK 2026.8.21, and
|
|
161
|
+
the extension manifest refuses older hosts through the same compatibility floor.
|
|
162
|
+
Commands that render their own text or JSON (`web status`, `web stop`, and
|
|
163
|
+
`web doctor`) return the public SDK output-suppression marker, so the host never
|
|
164
|
+
appends a second payload to stdout.
|
|
165
|
+
|
|
152
166
|
Optional OIDC uses Authorization Code flow with PKCE, provider discovery/JWKS,
|
|
153
167
|
signed state cookies, and issuer/subject identity mapping. It is disabled when
|
|
154
168
|
no OIDC variables are present and production startup fails closed on partial or
|
|
@@ -186,13 +200,29 @@ MIT
|
|
|
186
200
|
|
|
187
201
|
## Release Automation
|
|
188
202
|
|
|
189
|
-
|
|
203
|
+
CI runs type checking, exact Node 22.18.0 and current-Node matrices, real
|
|
204
|
+
PostgreSQL integration tests, the configured coverage gate, complete docstring
|
|
205
|
+
coverage, production dependency audit, package packing, fresh packed `npx` and
|
|
206
|
+
`bunx` install-and-command acceptance, immutable workflow-action checks, and
|
|
207
|
+
pm-changelog validation. The daily release
|
|
208
|
+
workflow publishes at most once when commits exist after the latest release tag
|
|
209
|
+
and uses pm-changelog for both `CHANGELOG.md` and GitHub release notes.
|
|
210
|
+
|
|
211
|
+
The current package is **not** approved for a new release: exact all-source
|
|
212
|
+
100/100/100/100 coverage remains open in
|
|
213
|
+
[`pm-web-9ulj`](.agents/pm/epics/pm-web-9ulj.toon) and
|
|
214
|
+
[`pm-web-ulgy`](.agents/pm/tasks/pm-web-ulgy.toon), while reachable-history
|
|
215
|
+
privacy authorization is tracked in
|
|
216
|
+
[`pm-web-priv`](.agents/pm/issues/pm-web-priv.toon) and
|
|
217
|
+
[GitHub issue #96](https://github.com/unbraind/pm-web/issues/96). Passing the
|
|
218
|
+
configured gate is evidence for the measured source set, not those independent
|
|
219
|
+
release approvals.
|
|
190
220
|
|
|
191
221
|
## New data endpoints (kanban board & search)
|
|
192
222
|
|
|
193
|
-
The pm data API
|
|
194
|
-
|
|
195
|
-
|
|
223
|
+
The pm data API exposes board and search views through the certified
|
|
224
|
+
complete-read contract described above. The board also uses the workspace's
|
|
225
|
+
live `pm contracts` statuses for its columns:
|
|
196
226
|
|
|
197
227
|
- `GET /api/projects/:projectId/pm/board` — items grouped into kanban columns by
|
|
198
228
|
the workspace's runtime statuses (unlisted statuses fall into `(other)`).
|
package/dist/app.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Express } from "express";
|
|
2
|
+
import { type HealthProbeDeps } from "./health.ts";
|
|
2
3
|
/**
|
|
3
4
|
* Legal pages served as standalone HTML (not part of the SPA bundle).
|
|
4
5
|
*
|
|
@@ -34,10 +35,33 @@ export declare const LEGAL_REDIRECTS: Record<string, string>;
|
|
|
34
35
|
* @returns The real, resolved directory path holding the legal HTML files.
|
|
35
36
|
*/
|
|
36
37
|
export declare function resolveLegalPagesDir(env?: NodeJS.ProcessEnv): string;
|
|
38
|
+
/**
|
|
39
|
+
* Optional dependencies `createApp` can wire into the application.
|
|
40
|
+
*
|
|
41
|
+
* Currently only the {@link HealthProbeDeps} for the real `/healthz` handler is
|
|
42
|
+
* supported; the object is kept as a bag so further production-only wiring
|
|
43
|
+
* (e.g. a logger) can be added later without another signature change.
|
|
44
|
+
*/
|
|
45
|
+
export interface CreateAppDeps {
|
|
46
|
+
/**
|
|
47
|
+
* Health-probe dependencies for the real `/healthz` handler. When supplied,
|
|
48
|
+
* `createApp` mounts {@link createHealthHandler}, which probes PostgreSQL and
|
|
49
|
+
* the projects volume and answers 200/503 accordingly. When omitted, the
|
|
50
|
+
* route answers 503 `ok:false` (see the comment at the route) so a
|
|
51
|
+
* misconfigured deployment can never report healthy while its dependencies
|
|
52
|
+
* are down — the exact failure this handler was written to close.
|
|
53
|
+
*/
|
|
54
|
+
readonly health?: HealthProbeDeps;
|
|
55
|
+
}
|
|
37
56
|
/**
|
|
38
57
|
* Build the Express application with all middleware, static assets, legal
|
|
39
58
|
* page routes, API routes and the SPA fallback — but WITHOUT touching the
|
|
40
59
|
* database or binding a port. Splitting this out from server.ts keeps the
|
|
41
60
|
* HTTP surface unit-testable without a running PostgreSQL instance.
|
|
61
|
+
*
|
|
62
|
+
* @param deps - Optional production wiring. Pass `health` to mount the real
|
|
63
|
+
* probing `/healthz` handler; omit it for tests that only need the HTTP
|
|
64
|
+
* surface (the route then answers 503 `ok:false`, never `ok:true`).
|
|
65
|
+
* @returns The configured Express application, not yet listening on a port.
|
|
42
66
|
*/
|
|
43
|
-
export declare function createApp(): Express;
|
|
67
|
+
export declare function createApp(deps?: CreateAppDeps): Express;
|
package/dist/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import cookieParser from "cookie-parser";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { accessSync, constants, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createHealthHandler } from "./health.js";
|
|
6
7
|
import { authRouter } from "./routes/auth.js";
|
|
7
8
|
import { oidcRouter } from "./routes/oidc.js";
|
|
8
9
|
import { projectsRouter } from "./routes/projects.js";
|
|
@@ -12,6 +13,8 @@ import { groupsRouter } from "./routes/groups.js";
|
|
|
12
13
|
import { sharesRouter, sharedWithMeRouter } from "./routes/sharing.js";
|
|
13
14
|
import { githubRouter } from "./routes/github.js";
|
|
14
15
|
import { adminRouter } from "./routes/admin.js";
|
|
16
|
+
import { createTierLimiters, resolveTrustProxy } from "./rate-limit.js";
|
|
17
|
+
import { csrfProtection } from "./csrf.js";
|
|
15
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
19
|
const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
|
|
17
20
|
/**
|
|
@@ -95,13 +98,31 @@ export function resolveLegalPagesDir(env = process.env) {
|
|
|
95
98
|
* page routes, API routes and the SPA fallback — but WITHOUT touching the
|
|
96
99
|
* database or binding a port. Splitting this out from server.ts keeps the
|
|
97
100
|
* HTTP surface unit-testable without a running PostgreSQL instance.
|
|
101
|
+
*
|
|
102
|
+
* @param deps - Optional production wiring. Pass `health` to mount the real
|
|
103
|
+
* probing `/healthz` handler; omit it for tests that only need the HTTP
|
|
104
|
+
* surface (the route then answers 503 `ok:false`, never `ok:true`).
|
|
105
|
+
* @returns The configured Express application, not yet listening on a port.
|
|
98
106
|
*/
|
|
99
|
-
export function createApp() {
|
|
107
|
+
export function createApp(deps) {
|
|
100
108
|
const app = express();
|
|
101
109
|
const legalPagesDir = resolveLegalPagesDir();
|
|
110
|
+
// Trust the reverse proxy so `req.ip`, `req.hostname` and `req.protocol`
|
|
111
|
+
// reflect the real client behind Caddy instead of the proxy itself. This is
|
|
112
|
+
// what the rate limiter keys on: without it every client would share one
|
|
113
|
+
// bucket (the proxy's address) and the limiter would be useless. The hop
|
|
114
|
+
// count is configurable via PM_WEB_TRUST_PROXY; the default trusts a single
|
|
115
|
+
// hop, matching the documented Caddy deployment, and never `true` (which
|
|
116
|
+
// would let callers forge X-Forwarded-For and bypass per-client limits).
|
|
117
|
+
app.set("trust proxy", resolveTrustProxy(process.env));
|
|
118
|
+
// One set of tier limiters for this app instance; each owns an independent
|
|
119
|
+
// in-memory store so the tiers never steal each other's quota. See
|
|
120
|
+
// src/rate-limit.ts for the per-tier limits and the abuse each protects
|
|
121
|
+
// against.
|
|
122
|
+
const limiters = createTierLimiters(process.env);
|
|
102
123
|
app.use(express.json({ limit: "1mb" }));
|
|
103
124
|
app.use(cookieParser());
|
|
104
|
-
app.get("/sw.js", (_req, res) => {
|
|
125
|
+
app.get("/sw.js", limiters.staticAssets, (_req, res) => {
|
|
105
126
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
|
|
106
127
|
res.setHeader("Pragma", "no-cache");
|
|
107
128
|
res.setHeader("Expires", "0");
|
|
@@ -128,14 +149,32 @@ export function createApp() {
|
|
|
128
149
|
return "unknown";
|
|
129
150
|
}
|
|
130
151
|
})();
|
|
131
|
-
|
|
152
|
+
if (deps?.health) {
|
|
153
|
+
// Production wiring: probe PostgreSQL and the projects volume before
|
|
154
|
+
// answering. `server.ts` always supplies these dependencies, so the
|
|
155
|
+
// deployed service reports healthy only when its hard dependencies are
|
|
156
|
+
// actually reachable.
|
|
157
|
+
app.get("/healthz", createHealthHandler(deps.health));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// No health-probe dependencies were supplied, so this route cannot probe
|
|
161
|
+
// PostgreSQL or the projects volume. Answering `ok:true` here would
|
|
162
|
+
// reproduce the original bug this handler exists to close: pm-web has
|
|
163
|
+
// previously served frozen data for two days while `/healthz` reported
|
|
164
|
+
// healthy. A route that cannot probe must not claim `ok:true`, so the
|
|
165
|
+
// unconfigured default answers 503 with the version only. Production
|
|
166
|
+
// (`server.ts`) always supplies the real dependencies; this branch is
|
|
167
|
+
// reached only by tests that exercise the HTTP surface without a database
|
|
168
|
+
// and by misconfigured deployments, which must fail loud rather than lie.
|
|
169
|
+
app.get("/healthz", (_req, res) => res.status(503).json({ ok: false, version: PM_WEB_VERSION }));
|
|
170
|
+
}
|
|
132
171
|
const legalPages = new Set(LEGAL_PAGES);
|
|
133
172
|
Object.entries(LEGAL_REDIRECTS).forEach(([from, to]) => {
|
|
134
173
|
app.get(from, (_req, res) => {
|
|
135
174
|
res.redirect(308, to);
|
|
136
175
|
});
|
|
137
176
|
});
|
|
138
|
-
app.get(["/legal-notice", "/privacy-policy", "/terms", "/cookie-settings"], (req, res) => {
|
|
177
|
+
app.get(["/legal-notice", "/privacy-policy", "/terms", "/cookie-settings"], limiters.staticAssets, (req, res) => {
|
|
139
178
|
// Non-strict routing also matches trailing-slash variants (/terms/), so
|
|
140
179
|
// normalize before the whitelist lookup instead of 404ing on them.
|
|
141
180
|
const page = req.path.replace(/\/+$/, "").slice(1);
|
|
@@ -146,24 +185,45 @@ export function createApp() {
|
|
|
146
185
|
res.setHeader("Cache-Control", "no-store");
|
|
147
186
|
res.sendFile(path.join(legalPagesDir, `${page}.html`));
|
|
148
187
|
});
|
|
149
|
-
//
|
|
188
|
+
// CSRF guard: issued after cookie-parser (it reads/sets the csrf cookie) and
|
|
189
|
+
// before the API routers so every cookie-authenticated state-changing route
|
|
190
|
+
// is guarded. See src/csrf.ts.
|
|
191
|
+
app.use(csrfProtection());
|
|
192
|
+
// API routes. Each mount sits behind the tier that matches its abuse profile:
|
|
193
|
+
// - /api/auth → auth tier (tightest): credential brute-force / account abuse.
|
|
194
|
+
// - /api/admin → admin tier (tight): privileged operations, rare in normal use.
|
|
195
|
+
// - every other /api mount → read tier (GET/HEAD/OPTIONS) + write tier
|
|
196
|
+
// (POST/PATCH/PUT/DELETE), split by method via the limiters' `skip`
|
|
197
|
+
// predicates so reads and writes carry separate per-minute budgets.
|
|
198
|
+
// Real-time collaborative editing generates many requests per user, but a
|
|
199
|
+
// single human stays well under 300 writes/min and 600 reads/min; the limits
|
|
200
|
+
// stop scripted floods without throttling normal collaboration.
|
|
201
|
+
// A limiter counts every request that traverses it, so it must be mounted at
|
|
202
|
+
// exactly one point per path. Repeating it on a prefix and again on a nested
|
|
203
|
+
// mount charged one request twice and silently halved the published budget:
|
|
204
|
+
// /api/auth fell through oidcRouter into authRouter, and every
|
|
205
|
+
// /api/projects/... request matched the /api/projects prefix before its own
|
|
206
|
+
// nested mount. Each shared prefix therefore carries its limiters once, and
|
|
207
|
+
// the routers mount behind them.
|
|
208
|
+
app.use("/api/auth", limiters.auth);
|
|
150
209
|
app.use("/api/auth", oidcRouter);
|
|
151
210
|
app.use("/api/auth", authRouter);
|
|
211
|
+
app.use("/api/projects", limiters.read, limiters.write);
|
|
152
212
|
app.use("/api/projects", projectsRouter);
|
|
153
213
|
app.use("/api/projects/:projectId/pm", pmRouter);
|
|
154
214
|
app.use("/api/projects/:projectId/extensions", extensionsRouter);
|
|
155
|
-
app.use("/api/groups", groupsRouter);
|
|
156
215
|
app.use("/api/projects/:id/shares", sharesRouter);
|
|
157
|
-
app.use("/api/shared", sharedWithMeRouter);
|
|
158
216
|
app.use("/api/projects/:id/github", githubRouter);
|
|
159
|
-
app.use("/api/
|
|
217
|
+
app.use("/api/groups", limiters.read, limiters.write, groupsRouter);
|
|
218
|
+
app.use("/api/shared", limiters.read, limiters.write, sharedWithMeRouter);
|
|
219
|
+
app.use("/api/admin", limiters.admin, adminRouter);
|
|
160
220
|
// Unknown API routes get a JSON 404 instead of falling through to the SPA
|
|
161
221
|
// shell, which would hand HTML to API clients expecting JSON.
|
|
162
222
|
app.all("/api/{*splat}", (_req, res) => {
|
|
163
223
|
res.status(404).json({ error: "Not found" });
|
|
164
224
|
});
|
|
165
225
|
// SPA fallback — serve index.html for all non-API routes
|
|
166
|
-
app.get("/{*splat}", (_req, res) => {
|
|
226
|
+
app.get("/{*splat}", limiters.staticAssets, (_req, res) => {
|
|
167
227
|
res.sendFile(path.join(PUBLIC_DIR, "index.html"));
|
|
168
228
|
});
|
|
169
229
|
return app;
|
package/dist/app.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,OAAyB,MAAM,SAAS,CAAC;AAChD,OAAO,YAAY,MAAM,eAAe,CAAC;AACzC,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACjG,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
package/dist/csrf.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Name of the double-submit CSRF token cookie.
|
|
4
|
+
*
|
|
5
|
+
* The name contains `csrf` so static analyzers (CodeQL's `js/missing-token-validation`)
|
|
6
|
+
* recognise the cookie-setting handler as a CSRF protector, and so a future
|
|
7
|
+
* SPA enhancement can read it (the cookie is intentionally **not** `HttpOnly`)
|
|
8
|
+
* and echo it back as an `X-CSRF-Token` header for the synchronized-token
|
|
9
|
+
* defence. The active defence today is the same-origin check in
|
|
10
|
+
* {@link isCrossSiteRequest}; the cookie keeps the double-submit path open and
|
|
11
|
+
* makes the middleware's protective intent machine-readable.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CSRF_COOKIE_NAME = "csrf_token";
|
|
14
|
+
/**
|
|
15
|
+
* Decide whether an unsafe request crossed sites, using the unforgeable
|
|
16
|
+
* `Sec-Fetch-Site` header first and the `Origin` header as a fallback.
|
|
17
|
+
*
|
|
18
|
+
* `Sec-Fetch-Site` is set by the browser fetch metadata spec and cannot be
|
|
19
|
+
* spoofed from JavaScript, so `cross-site` is conclusive. When it is absent
|
|
20
|
+
* (older browsers, non-browser clients) the `Origin` header is compared
|
|
21
|
+
* against the request's own `Host`: a browser always sends `Origin` on
|
|
22
|
+
* unsafe requests, so a present, mismatched `Origin` is also conclusive. A
|
|
23
|
+
* missing `Origin` means the request is not browser-initiated and therefore
|
|
24
|
+
* cannot be a CSRF attack, so it is allowed. A malformed `Origin` is treated
|
|
25
|
+
* as not-cross-site rather than blocked, so the guard never produces a false
|
|
26
|
+
* positive that would block a legitimate (if quirky) client.
|
|
27
|
+
*
|
|
28
|
+
* @param req - The Express request to inspect.
|
|
29
|
+
* @returns `true` when the request is provably cross-site and should be blocked.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isCrossSiteRequest(req: Request): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Build the CSRF protection middleware.
|
|
34
|
+
*
|
|
35
|
+
* On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
|
|
36
|
+
* absent, and on every **cookie-authenticated, state-changing** request it
|
|
37
|
+
* blocks provably cross-site callers with 403. Safe methods and unauthenticated
|
|
38
|
+
* requests pass through, so login/register (no session cookie) and reads are
|
|
39
|
+
* unaffected. The same-origin check relies on browser-fetch-metadata headers
|
|
40
|
+
* that are absent from Node's `fetch`, so the real-Postgres route suite —
|
|
41
|
+
* which drives the app with cookie-authenticated `fetch` calls and no `Origin`
|
|
42
|
+
* — is never blocked, while a real browser CSRF attempt (which always sends
|
|
43
|
+
* `Sec-Fetch-Site: cross-site` or a foreign `Origin`) is.
|
|
44
|
+
*
|
|
45
|
+
* @returns An Express middleware implementing the double-submit-cookie plus
|
|
46
|
+
* same-origin defence.
|
|
47
|
+
*/
|
|
48
|
+
export declare function csrfProtection(): RequestHandler;
|
package/dist/csrf.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { isUnsafeMethod } from "./rate-limit.js";
|
|
3
|
+
/**
|
|
4
|
+
* Name of the double-submit CSRF token cookie.
|
|
5
|
+
*
|
|
6
|
+
* The name contains `csrf` so static analyzers (CodeQL's `js/missing-token-validation`)
|
|
7
|
+
* recognise the cookie-setting handler as a CSRF protector, and so a future
|
|
8
|
+
* SPA enhancement can read it (the cookie is intentionally **not** `HttpOnly`)
|
|
9
|
+
* and echo it back as an `X-CSRF-Token` header for the synchronized-token
|
|
10
|
+
* defence. The active defence today is the same-origin check in
|
|
11
|
+
* {@link isCrossSiteRequest}; the cookie keeps the double-submit path open and
|
|
12
|
+
* makes the middleware's protective intent machine-readable.
|
|
13
|
+
*/
|
|
14
|
+
export const CSRF_COOKIE_NAME = "csrf_token";
|
|
15
|
+
/**
|
|
16
|
+
* Values of the `Sec-Fetch-Site` request header that prove a request did NOT
|
|
17
|
+
* cross sites. `none` is a user-initiated navigation (no referrer) and is safe;
|
|
18
|
+
* `same-origin` and `same-site` are, by definition, not cross-site. Any other
|
|
19
|
+
* value (`cross-site`) is a forgery attempt and is blocked.
|
|
20
|
+
*/
|
|
21
|
+
const SAME_FETCH_SITES = new Set(["same-origin", "same-site", "none"]);
|
|
22
|
+
/**
|
|
23
|
+
* Read the `pm_token` session cookie from a request that has already been
|
|
24
|
+
* through `cookie-parser`.
|
|
25
|
+
*
|
|
26
|
+
* The CSRF guard only enforces same-origin on requests that are actually
|
|
27
|
+
* authenticated with the session cookie: a cross-site `POST /api/auth/login`
|
|
28
|
+
* carries no `pm_token` (the victim has not logged in from the attacker's
|
|
29
|
+
* page), so it cannot act on the victim's behalf and need not be blocked.
|
|
30
|
+
* Bearer-token API clients (which send `Authorization` and no cookie) are
|
|
31
|
+
* likewise exempt, which is why the guard keys on the cookie and not on
|
|
32
|
+
* `requireAuth`.
|
|
33
|
+
*
|
|
34
|
+
* @param req - The Express request, post-`cookie-parser`.
|
|
35
|
+
* @returns `true` when a `pm_token` session cookie is present.
|
|
36
|
+
*/
|
|
37
|
+
function hasSessionCookie(req) {
|
|
38
|
+
const cookies = req.cookies;
|
|
39
|
+
return Boolean(cookies && cookies.pm_token);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether an unsafe request crossed sites, using the unforgeable
|
|
43
|
+
* `Sec-Fetch-Site` header first and the `Origin` header as a fallback.
|
|
44
|
+
*
|
|
45
|
+
* `Sec-Fetch-Site` is set by the browser fetch metadata spec and cannot be
|
|
46
|
+
* spoofed from JavaScript, so `cross-site` is conclusive. When it is absent
|
|
47
|
+
* (older browsers, non-browser clients) the `Origin` header is compared
|
|
48
|
+
* against the request's own `Host`: a browser always sends `Origin` on
|
|
49
|
+
* unsafe requests, so a present, mismatched `Origin` is also conclusive. A
|
|
50
|
+
* missing `Origin` means the request is not browser-initiated and therefore
|
|
51
|
+
* cannot be a CSRF attack, so it is allowed. A malformed `Origin` is treated
|
|
52
|
+
* as not-cross-site rather than blocked, so the guard never produces a false
|
|
53
|
+
* positive that would block a legitimate (if quirky) client.
|
|
54
|
+
*
|
|
55
|
+
* @param req - The Express request to inspect.
|
|
56
|
+
* @returns `true` when the request is provably cross-site and should be blocked.
|
|
57
|
+
*/
|
|
58
|
+
export function isCrossSiteRequest(req) {
|
|
59
|
+
const site = req.headers["sec-fetch-site"];
|
|
60
|
+
if (site === "cross-site")
|
|
61
|
+
return true;
|
|
62
|
+
if (typeof site === "string" && SAME_FETCH_SITES.has(site))
|
|
63
|
+
return false;
|
|
64
|
+
const origin = req.headers["origin"];
|
|
65
|
+
if (typeof origin !== "string" || origin === "")
|
|
66
|
+
return false;
|
|
67
|
+
let originHost;
|
|
68
|
+
try {
|
|
69
|
+
originHost = new URL(origin).host;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return originHost !== (req.get("host") ?? "");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Set the double-submit CSRF token cookie when the request does not already
|
|
78
|
+
* carry one, so a SPA can read and replay it.
|
|
79
|
+
*
|
|
80
|
+
* The cookie is `SameSite=Lax`, `Secure` in production and **not** `HttpOnly`,
|
|
81
|
+
* the last being the point: the SPA needs to read it to send it back as a
|
|
82
|
+
* header. It carries no secret — it is a random token whose only job is to be
|
|
83
|
+
* the same on the way out and the way back.
|
|
84
|
+
*
|
|
85
|
+
* @param req - The Express request, post-`cookie-parser`.
|
|
86
|
+
* @param res - The Express response to attach the cookie to.
|
|
87
|
+
*/
|
|
88
|
+
function ensureCsrfCookie(req, res) {
|
|
89
|
+
const cookies = req.cookies;
|
|
90
|
+
if (cookies?.[CSRF_COOKIE_NAME])
|
|
91
|
+
return;
|
|
92
|
+
res.cookie(CSRF_COOKIE_NAME, crypto.randomBytes(24).toString("base64url"), {
|
|
93
|
+
sameSite: "lax",
|
|
94
|
+
httpOnly: false,
|
|
95
|
+
secure: process.env.NODE_ENV === "production",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build the CSRF protection middleware.
|
|
100
|
+
*
|
|
101
|
+
* On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
|
|
102
|
+
* absent, and on every **cookie-authenticated, state-changing** request it
|
|
103
|
+
* blocks provably cross-site callers with 403. Safe methods and unauthenticated
|
|
104
|
+
* requests pass through, so login/register (no session cookie) and reads are
|
|
105
|
+
* unaffected. The same-origin check relies on browser-fetch-metadata headers
|
|
106
|
+
* that are absent from Node's `fetch`, so the real-Postgres route suite —
|
|
107
|
+
* which drives the app with cookie-authenticated `fetch` calls and no `Origin`
|
|
108
|
+
* — is never blocked, while a real browser CSRF attempt (which always sends
|
|
109
|
+
* `Sec-Fetch-Site: cross-site` or a foreign `Origin`) is.
|
|
110
|
+
*
|
|
111
|
+
* @returns An Express middleware implementing the double-submit-cookie plus
|
|
112
|
+
* same-origin defence.
|
|
113
|
+
*/
|
|
114
|
+
export function csrfProtection() {
|
|
115
|
+
return (req, res, next) => {
|
|
116
|
+
ensureCsrfCookie(req, res);
|
|
117
|
+
if (!isUnsafeMethod(req.method) || !hasSessionCookie(req) || !isCrossSiteRequest(req)) {
|
|
118
|
+
next();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
res.status(403).json({ error: "Cross-origin request blocked" });
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=csrf.js.map
|
package/dist/csrf.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"csrf.js","sourceRoot":"","sources":["../src/csrf.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC;AAE7C;;;;;GAKG;AACH,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;AAEvE;;;;;;;;;;;;;;GAcG;AACH,SAAS,gBAAgB,CAAC,GAAY;IACpC,MAAM,OAAO,GAAI,GAAkE,CAAC,OAAO,CAAC;IAC5F,OAAO,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAY;IAC7C,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC3C,IAAI,IAAI,KAAK,YAAY;QAAE,OAAO,IAAI,CAAC;IACvC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IACzE,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACrC,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAC9D,IAAI,UAAkB,CAAC;IACvB,IAAI,CAAC;QACH,UAAU,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,UAAU,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAS,gBAAgB,CAAC,GAAY,EAAE,GAAa;IACnD,MAAM,OAAO,GAAI,GAAkE,CAAC,OAAO,CAAC;IAC5F,IAAI,OAAO,EAAE,CAAC,gBAAgB,CAAC;QAAE,OAAO;IACxC,GAAG,CAAC,MAAM,CAAC,gBAAgB,EAAE,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE;QACzE,QAAQ,EAAE,KAAK;QACf,QAAQ,EAAE,KAAK;QACf,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;KAC9C,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,cAAc;IAC5B,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,gBAAgB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAC3B,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;YACtF,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QACD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,8BAA8B,EAAE,CAAC,CAAC;IAClE,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/health.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { RequestHandler } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Minimum structural type a PostgreSQL pool must satisfy for the health probe.
|
|
4
|
+
*
|
|
5
|
+
* The real `pg.Pool` satisfies this, and tests can substitute any object whose
|
|
6
|
+
* `query` accepts a SQL string and returns a promise — so the probe runs
|
|
7
|
+
* through the same pool the service uses in production without forcing tests
|
|
8
|
+
* to construct a real database connection.
|
|
9
|
+
*/
|
|
10
|
+
export interface Queryable {
|
|
11
|
+
/** Execute a SQL string and return rows. */
|
|
12
|
+
query(text: string): Promise<{
|
|
13
|
+
rows: unknown[];
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/** Status reported for one dependency in the health response. */
|
|
17
|
+
export interface DependencyStatus {
|
|
18
|
+
/** Whether the dependency responded successfully. */
|
|
19
|
+
readonly ok: boolean;
|
|
20
|
+
/** Round-trip latency in milliseconds. */
|
|
21
|
+
readonly latency_ms: number;
|
|
22
|
+
/** Human-readable failure reason; omitted on success. */
|
|
23
|
+
readonly error?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the dependency was configured (env var present). Only meaningful
|
|
26
|
+
* for soft dependencies that may legitimately be absent; hard dependencies
|
|
27
|
+
* are always treated as configured.
|
|
28
|
+
*/
|
|
29
|
+
readonly configured?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** A named soft-dependency probe whose result does not affect the status code. */
|
|
32
|
+
export interface SoftProbe {
|
|
33
|
+
/** Key under `dependencies` in the response. */
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/** Async function returning the dependency status. */
|
|
36
|
+
readonly probe: () => Promise<DependencyStatus>;
|
|
37
|
+
}
|
|
38
|
+
/** Dependencies the health handler needs to probe. */
|
|
39
|
+
export interface HealthProbeDeps {
|
|
40
|
+
/** PostgreSQL pool — probed with `SELECT 1` (hard dependency). */
|
|
41
|
+
readonly pool: Queryable;
|
|
42
|
+
/** Host-mounted projects root — probed for writability (hard dependency). */
|
|
43
|
+
readonly projectsRoot: string;
|
|
44
|
+
/** Package version, resolved once at boot from `package.json`. */
|
|
45
|
+
readonly version: string;
|
|
46
|
+
/** Soft-dependency probes; their results are reported but never cause 503. */
|
|
47
|
+
readonly softProbes?: ReadonlyArray<SoftProbe>;
|
|
48
|
+
/**
|
|
49
|
+
* Timing overrides, in milliseconds.
|
|
50
|
+
*
|
|
51
|
+
* Production never sets these. They exist because the behaviour that matters
|
|
52
|
+
* here -- what happens on the second cooldown expiry of a stall that has not
|
|
53
|
+
* ended -- is otherwise only reachable by a test that waits over half a
|
|
54
|
+
* minute, and a guard nothing executes is how a pool-drain protection stops
|
|
55
|
+
* working without anyone noticing.
|
|
56
|
+
*/
|
|
57
|
+
readonly timing?: {
|
|
58
|
+
/** Overrides {@link PROBE_TIMEOUT_MS}. */
|
|
59
|
+
readonly probeTimeoutMs?: number;
|
|
60
|
+
/** Overrides {@link CACHE_TTL_MS}. */
|
|
61
|
+
readonly cacheTtlMs?: number;
|
|
62
|
+
/** Overrides {@link POOL_COOLDOWN_MS}. */
|
|
63
|
+
readonly poolCooldownMs?: number;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Shape of the `/healthz` response body (backward compatible: `ok`, `version`). */
|
|
67
|
+
export interface HealthResult {
|
|
68
|
+
/** `true` only when every hard dependency is reachable. */
|
|
69
|
+
readonly ok: boolean;
|
|
70
|
+
/** Package version from `package.json`. */
|
|
71
|
+
readonly version: string;
|
|
72
|
+
/** Per-dependency breakdown. */
|
|
73
|
+
readonly dependencies: Readonly<Record<string, DependencyStatus>>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the Express `/healthz` request handler.
|
|
77
|
+
*
|
|
78
|
+
* The handler probes every hard dependency in parallel, adds soft-dependency
|
|
79
|
+
* results, caches the outcome for {@link CACHE_TTL_MS} milliseconds, and
|
|
80
|
+
* responds with HTTP 200 (`ok: true`) or 503 (`ok: false`). It never throws:
|
|
81
|
+
* each probe catches its own errors, and the outer `try`/`catch` is a
|
|
82
|
+
* defensive last resort.
|
|
83
|
+
*/
|
|
84
|
+
export declare function createHealthHandler(deps: HealthProbeDeps): RequestHandler;
|