@unbrained/pm-web 2026.8.28 → 2026.8.31
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 +19 -3
- package/README.md +1 -0
- package/dist/app.js +43 -7
- package/dist/app.js.map +1 -1
- package/dist/csrf.d.ts +44 -0
- package/dist/csrf.js +116 -0
- package/dist/csrf.js.map +1 -0
- package/dist/index.js +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 +38 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/github.d.ts +19 -0
- package/dist/routes/github.js +78 -16
- package/dist/routes/github.js.map +1 -1
- package/dist/routes/pm.js +4 -1
- package/dist/routes/pm.js.map +1 -1
- package/dist/services/package-catalog.js +1 -1
- package/dist/services/package-catalog.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +8 -5
- package/public/src/api.js +25 -1
- package/public/src/api.js.map +1 -1
- package/public/src/api.ts +25 -2
- package/public/src/app.js +6 -2
- package/public/src/app.js.map +1 -1
- package/public/src/app.ts +6 -2
- package/public/src/sw.ts +27 -3
- package/public/sw.js +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 2026.8.31 - 2026-08-31
|
|
4
4
|
|
|
5
5
|
### Fixed
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
7
|
+
- GitHub issue imports accepted path traversal values that steered the project owner's token ([pm-web-po1s](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-po1s.toon))
|
|
8
|
+
- CodeQL action updates split into mutually blocking pull requests ([pm-web-lx5w](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-lx5w.toon))
|
|
9
|
+
- Pin pm-changelog 2026.8.30 before the next release ([pm-web-5ucr](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-5ucr.toon))
|
|
10
|
+
- Regenerate the changelog after the release tag exists ([pm-web-a401](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-a401.toon))
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- Same-site sibling origins bypass the cookie-authenticated CSRF guard ([pm-web-413z](https://github.com/unbraind/pm-web/blob/main/.agents/pm/issues/pm-web-413z.toon))
|
|
15
|
+
|
|
16
|
+
## 2026.8.29 - 2026-08-29
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- 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))
|
|
21
|
+
- 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))
|
|
9
22
|
|
|
10
23
|
## 2026.8.28 - 2026-08-28
|
|
11
24
|
|
|
12
25
|
### Fixed
|
|
13
26
|
|
|
27
|
+
- 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))
|
|
28
|
+
- 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))
|
|
29
|
+
- 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))
|
|
14
30
|
- 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))
|
|
15
31
|
- 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))
|
|
16
32
|
- 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))
|
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 |
|
package/dist/app.js
CHANGED
|
@@ -13,6 +13,8 @@ import { groupsRouter } from "./routes/groups.js";
|
|
|
13
13
|
import { sharesRouter, sharedWithMeRouter } from "./routes/sharing.js";
|
|
14
14
|
import { githubRouter } from "./routes/github.js";
|
|
15
15
|
import { adminRouter } from "./routes/admin.js";
|
|
16
|
+
import { createTierLimiters, resolveTrustProxy } from "./rate-limit.js";
|
|
17
|
+
import { csrfProtection } from "./csrf.js";
|
|
16
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
|
|
18
20
|
/**
|
|
@@ -105,9 +107,22 @@ export function resolveLegalPagesDir(env = process.env) {
|
|
|
105
107
|
export function createApp(deps) {
|
|
106
108
|
const app = express();
|
|
107
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);
|
|
108
123
|
app.use(express.json({ limit: "1mb" }));
|
|
109
124
|
app.use(cookieParser());
|
|
110
|
-
app.get("/sw.js", (_req, res) => {
|
|
125
|
+
app.get("/sw.js", limiters.staticAssets, (_req, res) => {
|
|
111
126
|
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
|
|
112
127
|
res.setHeader("Pragma", "no-cache");
|
|
113
128
|
res.setHeader("Expires", "0");
|
|
@@ -159,7 +174,7 @@ export function createApp(deps) {
|
|
|
159
174
|
res.redirect(308, to);
|
|
160
175
|
});
|
|
161
176
|
});
|
|
162
|
-
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) => {
|
|
163
178
|
// Non-strict routing also matches trailing-slash variants (/terms/), so
|
|
164
179
|
// normalize before the whitelist lookup instead of 404ing on them.
|
|
165
180
|
const page = req.path.replace(/\/+$/, "").slice(1);
|
|
@@ -170,24 +185,45 @@ export function createApp(deps) {
|
|
|
170
185
|
res.setHeader("Cache-Control", "no-store");
|
|
171
186
|
res.sendFile(path.join(legalPagesDir, `${page}.html`));
|
|
172
187
|
});
|
|
173
|
-
//
|
|
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);
|
|
174
209
|
app.use("/api/auth", oidcRouter);
|
|
175
210
|
app.use("/api/auth", authRouter);
|
|
211
|
+
app.use("/api/projects", limiters.read, limiters.write);
|
|
176
212
|
app.use("/api/projects", projectsRouter);
|
|
177
213
|
app.use("/api/projects/:projectId/pm", pmRouter);
|
|
178
214
|
app.use("/api/projects/:projectId/extensions", extensionsRouter);
|
|
179
|
-
app.use("/api/groups", groupsRouter);
|
|
180
215
|
app.use("/api/projects/:id/shares", sharesRouter);
|
|
181
|
-
app.use("/api/shared", sharedWithMeRouter);
|
|
182
216
|
app.use("/api/projects/:id/github", githubRouter);
|
|
183
|
-
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);
|
|
184
220
|
// Unknown API routes get a JSON 404 instead of falling through to the SPA
|
|
185
221
|
// shell, which would hand HTML to API clients expecting JSON.
|
|
186
222
|
app.all("/api/{*splat}", (_req, res) => {
|
|
187
223
|
res.status(404).json({ error: "Not found" });
|
|
188
224
|
});
|
|
189
225
|
// SPA fallback — serve index.html for all non-API routes
|
|
190
|
-
app.get("/{*splat}", (_req, res) => {
|
|
226
|
+
app.get("/{*splat}", limiters.staticAssets, (_req, res) => {
|
|
191
227
|
res.sendFile(path.join(PUBLIC_DIR, "index.html"));
|
|
192
228
|
});
|
|
193
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,mBAAmB,EAAwB,MAAM,aAAa,CAAC;AACxE,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,44 @@
|
|
|
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 origins, using the unforgeable
|
|
16
|
+
* `Sec-Fetch-Site` header first and the `Origin` header as an exact 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. `same-site` is not:
|
|
20
|
+
* it includes sibling origins, so those requests must still prove an exact
|
|
21
|
+
* scheme-and-host match through `Origin`. A browser-classified sibling request
|
|
22
|
+
* without that proof fails closed. When Fetch Metadata is absent, an absent
|
|
23
|
+
* `Origin` preserves non-browser and server-to-server clients; a present
|
|
24
|
+
* malformed or mismatched `Origin` is browser evidence and fails closed.
|
|
25
|
+
*
|
|
26
|
+
* @param req - The Express request to inspect.
|
|
27
|
+
* @returns `true` when the request is provably cross-site and should be blocked.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isCrossSiteRequest(req: Request): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Build the CSRF protection middleware.
|
|
32
|
+
*
|
|
33
|
+
* On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
|
|
34
|
+
* absent. Every state-changing request blocks browser evidence of a foreign
|
|
35
|
+
* origin. Cookie-authenticated mutations additionally require that token in
|
|
36
|
+
* `X-CSRF-Token`, so an older browser or intermediary that omits both origin
|
|
37
|
+
* signals cannot silently bypass the boundary. Login remains usable before a
|
|
38
|
+
* session exists; non-cookie server clients remain compatible. Safe methods
|
|
39
|
+
* pass through and bootstrap the token for a returning browser session.
|
|
40
|
+
*
|
|
41
|
+
* @returns An Express middleware implementing the double-submit-cookie plus
|
|
42
|
+
* same-origin defence.
|
|
43
|
+
*/
|
|
44
|
+
export declare function csrfProtection(): RequestHandler;
|
package/dist/csrf.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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 this application's origin. `none` is a direct user-agent navigation;
|
|
18
|
+
* `same-origin` has the same scheme, host and port. `same-site` is deliberately
|
|
19
|
+
* absent because it includes sibling subdomains, which are separate public
|
|
20
|
+
* services and therefore outside pm-web's origin trust boundary.
|
|
21
|
+
*/
|
|
22
|
+
const TRUSTED_FETCH_SITES = new Set(["same-origin", "none"]);
|
|
23
|
+
/**
|
|
24
|
+
* Decide whether an unsafe request crossed origins, using the unforgeable
|
|
25
|
+
* `Sec-Fetch-Site` header first and the `Origin` header as an exact fallback.
|
|
26
|
+
*
|
|
27
|
+
* `Sec-Fetch-Site` is set by the browser fetch metadata spec and cannot be
|
|
28
|
+
* spoofed from JavaScript, so `cross-site` is conclusive. `same-site` is not:
|
|
29
|
+
* it includes sibling origins, so those requests must still prove an exact
|
|
30
|
+
* scheme-and-host match through `Origin`. A browser-classified sibling request
|
|
31
|
+
* without that proof fails closed. When Fetch Metadata is absent, an absent
|
|
32
|
+
* `Origin` preserves non-browser and server-to-server clients; a present
|
|
33
|
+
* malformed or mismatched `Origin` is browser evidence and fails closed.
|
|
34
|
+
*
|
|
35
|
+
* @param req - The Express request to inspect.
|
|
36
|
+
* @returns `true` when the request is provably cross-site and should be blocked.
|
|
37
|
+
*/
|
|
38
|
+
export function isCrossSiteRequest(req) {
|
|
39
|
+
const site = req.headers["sec-fetch-site"];
|
|
40
|
+
if (site === "cross-site")
|
|
41
|
+
return true;
|
|
42
|
+
if (typeof site === "string" && TRUSTED_FETCH_SITES.has(site))
|
|
43
|
+
return false;
|
|
44
|
+
const origin = req.headers["origin"];
|
|
45
|
+
if (typeof origin !== "string" || origin === "")
|
|
46
|
+
return site === "same-site";
|
|
47
|
+
try {
|
|
48
|
+
const requestHost = req.host;
|
|
49
|
+
if (!requestHost)
|
|
50
|
+
return true;
|
|
51
|
+
return new URL(origin).origin !== new URL(`${req.protocol}://${requestHost}`).origin;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Set the double-submit CSRF token cookie when the request does not already
|
|
59
|
+
* carry one, so a SPA can read and replay it.
|
|
60
|
+
*
|
|
61
|
+
* The cookie is `SameSite=Lax`, `Secure` in production and **not** `HttpOnly`,
|
|
62
|
+
* the last being the point: the SPA needs to read it to send it back as a
|
|
63
|
+
* header. It carries no secret — it is a random token whose only job is to be
|
|
64
|
+
* the same on the way out and the way back.
|
|
65
|
+
*
|
|
66
|
+
* @param req - The Express request, post-`cookie-parser`.
|
|
67
|
+
* @param res - The Express response to attach the cookie and bootstrap header to.
|
|
68
|
+
*/
|
|
69
|
+
function ensureCsrfCookie(req, res) {
|
|
70
|
+
const cookies = req.cookies;
|
|
71
|
+
const token = cookies?.[CSRF_COOKIE_NAME] ?? crypto.randomBytes(24).toString("base64url");
|
|
72
|
+
if (!cookies?.[CSRF_COOKIE_NAME]) {
|
|
73
|
+
res.cookie(CSRF_COOKIE_NAME, token, {
|
|
74
|
+
sameSite: "lax",
|
|
75
|
+
httpOnly: false,
|
|
76
|
+
secure: process.env.NODE_ENV === "production",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// A service worker cannot read document.cookie. Exposing the same token on
|
|
80
|
+
// same-origin responses lets an upgraded worker replay pre-token queue
|
|
81
|
+
// records without deleting or rewriting a user's pending mutations.
|
|
82
|
+
res.setHeader("X-CSRF-Token", token);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build the CSRF protection middleware.
|
|
86
|
+
*
|
|
87
|
+
* On every request it (re)issues the {@link CSRF_COOKIE_NAME} token cookie when
|
|
88
|
+
* absent. Every state-changing request blocks browser evidence of a foreign
|
|
89
|
+
* origin. Cookie-authenticated mutations additionally require that token in
|
|
90
|
+
* `X-CSRF-Token`, so an older browser or intermediary that omits both origin
|
|
91
|
+
* signals cannot silently bypass the boundary. Login remains usable before a
|
|
92
|
+
* session exists; non-cookie server clients remain compatible. Safe methods
|
|
93
|
+
* pass through and bootstrap the token for a returning browser session.
|
|
94
|
+
*
|
|
95
|
+
* @returns An Express middleware implementing the double-submit-cookie plus
|
|
96
|
+
* same-origin defence.
|
|
97
|
+
*/
|
|
98
|
+
export function csrfProtection() {
|
|
99
|
+
return (req, res, next) => {
|
|
100
|
+
ensureCsrfCookie(req, res);
|
|
101
|
+
const cookies = req.cookies;
|
|
102
|
+
const sessionCookie = cookies?.pm_token;
|
|
103
|
+
const cookieToken = cookies?.[CSRF_COOKIE_NAME];
|
|
104
|
+
const headerToken = req.get("x-csrf-token");
|
|
105
|
+
const validDoubleSubmit = typeof cookieToken === "string" &&
|
|
106
|
+
cookieToken.length > 0 &&
|
|
107
|
+
headerToken === cookieToken;
|
|
108
|
+
if (!isUnsafeMethod(req.method) ||
|
|
109
|
+
(!isCrossSiteRequest(req) && (!sessionCookie || validDoubleSubmit))) {
|
|
110
|
+
next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
res.status(403).json({ error: "Cross-origin request blocked" });
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
//# 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":"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"}
|
package/dist/index.js
CHANGED
|
@@ -262,7 +262,7 @@ function processAlive(pid) {
|
|
|
262
262
|
}
|
|
263
263
|
export default defineExtension({
|
|
264
264
|
name: "pm-web",
|
|
265
|
-
version: "2026.8.
|
|
265
|
+
version: "2026.8.31",
|
|
266
266
|
activate(api) {
|
|
267
267
|
// -----------------------------------------------------------------------
|
|
268
268
|
// Command: pm web [--port <port>] [--detach]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Request, Response } from "express";
|
|
2
|
+
import { type RateLimitRequestHandler } from "express-rate-limit";
|
|
3
|
+
/**
|
|
4
|
+
* The default length of every rate-limit window, in milliseconds (one minute).
|
|
5
|
+
*
|
|
6
|
+
* All tiers share a one-minute window so a caller's quota is intuitive ("N
|
|
7
|
+
* requests per minute") and so a burst that exhausts a bucket recovers in at
|
|
8
|
+
* most a minute rather than the 15 minutes a longer window would lock in.
|
|
9
|
+
*/
|
|
10
|
+
export declare const RATE_LIMIT_WINDOW_MS = 60000;
|
|
11
|
+
/**
|
|
12
|
+
* Production default limits (requests per minute per client) for each tier.
|
|
13
|
+
*
|
|
14
|
+
* These are the values a deployed pm-web uses when no `PM_WEB_RATE_LIMIT_*`
|
|
15
|
+
* override is set. They are intentionally exported so the PR body and tests can
|
|
16
|
+
* reference the exact numbers the limiter ships with, rather than restating a
|
|
17
|
+
* second copy that would drift from the real configuration.
|
|
18
|
+
*/
|
|
19
|
+
/** Tightest tier: credential routes. 20/min lets a user retry a typo while making password brute-force impractical (bcrypt cost 12 ≈ 250 ms each). */
|
|
20
|
+
export declare const AUTH_LIMIT_PER_MINUTE = 20;
|
|
21
|
+
/** Admin tier: privileged operations are rare in normal use, so 30/min is ample and keeps scripted admin abuse bounded. */
|
|
22
|
+
export declare const ADMIN_LIMIT_PER_MINUTE = 30;
|
|
23
|
+
/** Read tier: 600 authenticated reads/min is far above a single human's dashboard/SSE rate and stops listing/polling floods. */
|
|
24
|
+
export declare const READ_LIMIT_PER_MINUTE = 600;
|
|
25
|
+
/** Write tier: 300 mutations/min sits between auth and reads; a collaborative editor stays well under it while scripted floods do not. */
|
|
26
|
+
export declare const WRITE_LIMIT_PER_MINUTE = 300;
|
|
27
|
+
/** Static tier: file-serving reads are cheap; 600/min bounds fs-flood attempts without throttling normal page loads. */
|
|
28
|
+
export declare const STATIC_LIMIT_PER_MINUTE = 600;
|
|
29
|
+
/**
|
|
30
|
+
* Report whether an HTTP method is "safe" (read-only) for tier assignment.
|
|
31
|
+
*
|
|
32
|
+
* Express delivers `req.method` upper-cased, so the lookup is direct; the
|
|
33
|
+
* `toUpperCase` keeps the predicate correct for any caller that passes a
|
|
34
|
+
* mixed-case value (tests, custom servers) without introducing a branch the
|
|
35
|
+
* coverage gate would have to exercise.
|
|
36
|
+
*
|
|
37
|
+
* @param method - The HTTP method string to classify.
|
|
38
|
+
* @returns `true` for `GET`, `HEAD` and `OPTIONS`; `false` for every mutating method.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isSafeMethod(method: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Report whether an HTTP method mutates state and therefore belongs in the
|
|
43
|
+
* "write" tier (or, for the CSRF guard, is the kind of request a forged
|
|
44
|
+
* cross-site request would target).
|
|
45
|
+
*
|
|
46
|
+
* @param method - The HTTP method string to classify.
|
|
47
|
+
* @returns `true` for `POST`, `PATCH`, `PUT`, `DELETE`, …; `false` for safe methods.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isUnsafeMethod(method: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the Express `trust proxy` setting from the environment.
|
|
52
|
+
*
|
|
53
|
+
* pm-web runs behind a reverse proxy (Caddy), so `req.ip` is the proxy's
|
|
54
|
+
* address unless Express is told how many `X-Forwarded-For` hops to trust.
|
|
55
|
+
* Getting this wrong collapses every client into one rate-limit bucket
|
|
56
|
+
* (the proxy's IP), which both defeats the limiter and makes the CSRF
|
|
57
|
+
* same-origin check unreliable. This function never returns the boolean
|
|
58
|
+
* `true`: trusting every hop lets any caller spoof `X-Forwarded-For` and
|
|
59
|
+
* bypass per-client limits, and `express-rate-limit` refuses that setting.
|
|
60
|
+
*
|
|
61
|
+
* The default trusts NO hop. That is the only safe default here, because the
|
|
62
|
+
* unsafe direction is silent: every launch path this package documents --
|
|
63
|
+
* `docker run -p 4000:4000`, `npm start`, a pm-host container -- exposes
|
|
64
|
+
* Express directly, and trusting one hop there makes `req.ip` the value the
|
|
65
|
+
* caller wrote in `X-Forwarded-For`. An attacker rotating that header would
|
|
66
|
+
* draw a fresh bucket for every request and bypass all of these limits while
|
|
67
|
+
* the limiter reported itself as enforcing them. A deployment that really is
|
|
68
|
+
* behind a proxy fails in the visible direction instead: every client shares
|
|
69
|
+
* the proxy's address, the limit bites early, and an operator notices.
|
|
70
|
+
*
|
|
71
|
+
* Deployments behind a reverse proxy set `PM_WEB_TRUST_PROXY` to the hop count
|
|
72
|
+
* (`1` for the Caddy deployment), to a comma-separated IP/subnet allowlist, or
|
|
73
|
+
* to `false`/`0` to state the direct case explicitly.
|
|
74
|
+
*
|
|
75
|
+
* @param env - Environment to read `PM_WEB_TRUST_PROXY` from; defaults to
|
|
76
|
+
* `process.env` so production reads the live configuration.
|
|
77
|
+
* @returns A value Express accepts for `app.set("trust proxy", …)`: a
|
|
78
|
+
* non-negative hop count, a boolean `false`, or a comma-separated
|
|
79
|
+
* IP/subnet string.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveTrustProxy(env?: NodeJS.ProcessEnv): number | boolean | string;
|
|
82
|
+
/**
|
|
83
|
+
* Options accepted by {@link createRateLimiter}.
|
|
84
|
+
*
|
|
85
|
+
* `skip` mirrors `express-rate-limit`'s option of the same name: return
|
|
86
|
+
* `true` to keep a request out of this limiter's bucket, which is how the read
|
|
87
|
+
* and write tiers split a single router by HTTP method without a separate
|
|
88
|
+
* mount per method.
|
|
89
|
+
*/
|
|
90
|
+
export interface RateLimiterOptions {
|
|
91
|
+
/** Window length in milliseconds; the quota replenishes after this elapses. */
|
|
92
|
+
readonly windowMs: number;
|
|
93
|
+
/** Maximum requests allowed within the window before the limiter answers 429. */
|
|
94
|
+
readonly limit: number;
|
|
95
|
+
/** Policy name emitted in the `RateLimit-Policy` header for clients. */
|
|
96
|
+
readonly identifier: string;
|
|
97
|
+
/** Optional predicate; returning `true` exempts a request from this limiter. */
|
|
98
|
+
readonly skip?: (req: Request, res: Response) => boolean;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build a rate-limiting middleware from pm-web's shared configuration.
|
|
102
|
+
*
|
|
103
|
+
* Wraps `express-rate-limit` so every tier emits the same standard
|
|
104
|
+
* `RateLimit-*` headers (draft-6) and disables the legacy `X-RateLimit-*`
|
|
105
|
+
* headers, giving clients one consistent retry contract. The factory is
|
|
106
|
+
* exported so the integration test can build a limiter with a tiny limit and
|
|
107
|
+
* drive a route past it, proving the middleware actually returns 429 rather
|
|
108
|
+
* than merely being registered.
|
|
109
|
+
*
|
|
110
|
+
* @param options - Window, limit, identifier and optional `skip` predicate.
|
|
111
|
+
* @returns An Express middleware that rate-limits its mount.
|
|
112
|
+
*/
|
|
113
|
+
export declare function createRateLimiter(options: RateLimiterOptions): RateLimitRequestHandler;
|
|
114
|
+
/**
|
|
115
|
+
* The set of rate-limit middlewares {@link createApp} mounts, one per tier.
|
|
116
|
+
*
|
|
117
|
+
* Each limiter owns an independent in-memory store, so the auth tier, the
|
|
118
|
+
* admin tier, the shared read bucket, the shared write bucket and the static
|
|
119
|
+
* tier never steal quota from one another. A single `read`/`write` pair is
|
|
120
|
+
* shared across all the ordinary API mounts so a client has one aggregate
|
|
121
|
+
* read budget and one aggregate write budget per minute — simpler to reason
|
|
122
|
+
* about than a per-router bucket, and still far above any legitimate
|
|
123
|
+
* collaborative-editing rate.
|
|
124
|
+
*/
|
|
125
|
+
export interface TierLimiters {
|
|
126
|
+
/** Tightest tier: credential-checking and OIDC routes (`/api/auth`). */
|
|
127
|
+
readonly auth: RateLimitRequestHandler;
|
|
128
|
+
/** Tight tier: privileged administration routes (`/api/admin`). */
|
|
129
|
+
readonly admin: RateLimitRequestHandler;
|
|
130
|
+
/** Loose tier: authenticated reads across all ordinary API routers. */
|
|
131
|
+
readonly read: RateLimitRequestHandler;
|
|
132
|
+
/** Medium tier: mutations across all ordinary API routers. */
|
|
133
|
+
readonly write: RateLimitRequestHandler;
|
|
134
|
+
/** Loose tier: file-serving routes (`/sw.js`, legal pages, SPA fallback). */
|
|
135
|
+
readonly staticAssets: RateLimitRequestHandler;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Build the five tier limiters from the environment, with production defaults.
|
|
139
|
+
*
|
|
140
|
+
* Each limit is overridable with a `PM_WEB_RATE_LIMIT_*` variable so an
|
|
141
|
+
* operator can tune a tier for their deployment without a code change, and so
|
|
142
|
+
* the test harness can raise the limits high enough that the functional
|
|
143
|
+
* (real-Postgres) suite — which issues many requests per file — is never
|
|
144
|
+
* throttled while the dedicated limiter test exercises an isolated limiter.
|
|
145
|
+
*
|
|
146
|
+
* @param env - Environment to read the overrides from; defaults to `process.env`.
|
|
147
|
+
* @returns The five tier middlewares, each with its own hit-count store.
|
|
148
|
+
*/
|
|
149
|
+
export declare function createTierLimiters(env?: NodeJS.ProcessEnv): TierLimiters;
|