diff --git a/backend/utils/app.js b/backend/utils/app.js index 5471cf88..7ca996a8 100644 --- a/backend/utils/app.js +++ b/backend/utils/app.js @@ -37,8 +37,12 @@ export class ExpressApplication { this.app.use(this.common.baseHref + '/api/ecl', eclRoutes); this.app.use(this.common.baseHref, express.static(join(this.directoryName, '../..', 'frontend'))); this.app.use((req, res, next) => { - res.cookie('XSRF-TOKEN', req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''); // RTL Angular Frontend - res.setHeader('XSRF-TOKEN', req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''); // RTL Quickpay JQuery + // Generate the token once per request: with csrf-csrf every call mints a + // new token on a first visit, so calling twice would desync the cookie + // from the header and the _csrf cookie it must match. + const csrfToken = req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''; + res.cookie('XSRF-TOKEN', csrfToken); // RTL Angular Frontend + res.setHeader('XSRF-TOKEN', csrfToken); // RTL Quickpay JQuery res.sendFile(join(this.directoryName, '../..', 'frontend', 'index.html')); }); this.app.use((err, req, res, next) => { diff --git a/backend/utils/authCheck.js b/backend/utils/authCheck.js index e6ee9dd1..2e8d23ae 100644 --- a/backend/utils/authCheck.js +++ b/backend/utils/authCheck.js @@ -1,10 +1,10 @@ import jwt from 'jsonwebtoken'; -import csurf from 'csurf/index.js'; +import CSRF from './csrf.js'; import { Common } from './common.js'; import { Logger } from './logger.js'; const common = Common; const logger = Logger; -const csurfProtection = csurf({ cookie: true }); +const csurfProtection = CSRF.csrfProtection; export const isAuthenticated = (req, res, next) => { try { const token = req.headers.authorization.split(' ')[1]; diff --git a/backend/utils/csrf.js b/backend/utils/csrf.js index 94c3b41d..0a48cd6d 100644 --- a/backend/utils/csrf.js +++ b/backend/utils/csrf.js @@ -1,11 +1,26 @@ -import csurf from 'csurf/index.js'; +import { doubleCsrf } from 'csrf-csrf'; import { Logger } from './logger.js'; import { Common } from './common.js'; class CSRF { constructor() { - this.csrfProtection = csurf({ cookie: true }); this.logger = Logger; this.common = Common; + // Signed double-submit-cookie protection (replaces the deprecated csurf). + // The signed token lives in the httpOnly '_csrf' cookie; the client echoes + // the same token (read from the XSRF-TOKEN cookie set in app.ts) in a + // header. The cookie is not secure-only because RTL commonly serves plain + // HTTP (matching the session cookie); token sources match what csurf + // accepted. The error code EBADCSRFTOKEN is handled in app.ts. + this.doubleCsrfUtilities = doubleCsrf({ + getSecret: () => this.common.secret_key, + getSessionIdentifier: (req) => (req.session ? req.session.id : ''), + cookieName: '_csrf', + cookieOptions: { sameSite: 'strict', path: '/', secure: false, httpOnly: true }, + getCsrfTokenFromRequest: (req) => (req.body && req.body._csrf) || (req.query && req.query._csrf) || + req.headers['csrf-token'] || req.headers['xsrf-token'] || + req.headers['x-csrf-token'] || req.headers['x-xsrf-token'] + }); + this.csrfProtection = this.doubleCsrfUtilities.doubleCsrfProtection; } mount(app) { this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'CSRF', msg: 'Setting up CSRF..' }); diff --git a/package-lock.json b/package-lock.json index 04a74c4c..3d3b2d6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "buffer": "6.0.3", "cookie-parser": "1.4.7", "crypto-browserify": "3.12.1", - "csurf": "1.11.0", + "csrf-csrf": "4.0.3", "express": "5.2.1", "express-session": "1.18.2", "hocon-parser": "1.0.1", @@ -9274,18 +9274,48 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/csrf": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", - "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "node_modules/csrf-csrf": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-4.0.3.tgz", + "integrity": "sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==", + "license": "ISC", + "dependencies": { + "http-errors": "^2.0.0" + } + }, + "node_modules/csrf-csrf/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "rndm": "1.2.0", - "tsscmp": "1.0.6", - "uid-safe": "2.1.5" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csrf-csrf/node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/csrf-csrf/node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" } }, "node_modules/css-loader": { @@ -9367,31 +9397,6 @@ "node": ">=4" } }, - "node_modules/csurf": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", - "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", - "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", - "license": "MIT", - "dependencies": { - "cookie": "0.4.0", - "cookie-signature": "1.0.6", - "csrf": "3.1.0", - "http-errors": "~1.7.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/csurf/node_modules/cookie": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", - "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", @@ -11953,40 +11958,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-errors/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", @@ -16995,12 +16966,6 @@ "node": ">= 0.8" } }, - "node_modules/rndm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", - "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", - "license": "MIT" - }, "node_modules/roboto-fontface": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/roboto-fontface/-/roboto-fontface-0.10.0.tgz", @@ -17661,12 +17626,6 @@ "dev": true, "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", - "license": "ISC" - }, "node_modules/sha.js": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", @@ -18636,15 +18595,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -18769,15 +18719,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "license": "MIT", - "engines": { - "node": ">=0.6.x" - } - }, "node_modules/tsyringe": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", diff --git a/package.json b/package.json index 4b4fc103..50a06727 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "buffer": "6.0.3", "cookie-parser": "1.4.7", "crypto-browserify": "3.12.1", - "csurf": "1.11.0", + "csrf-csrf": "4.0.3", "express": "5.2.1", "express-session": "1.18.2", "hocon-parser": "1.0.1", diff --git a/release-notes/Release-notes-0.15.9.md b/release-notes/Release-notes-0.15.9.md index 7dee8b94..6c67f352 100644 --- a/release-notes/Release-notes-0.15.9.md +++ b/release-notes/Release-notes-0.15.9.md @@ -174,6 +174,29 @@ this release should add its entry under the appropriate section below. from Core Lightning and Eclair, message sign/verify, channel backup to disk, bad-invoice and node-unreachable error mapping) plus a clean lint and both production builds. +- **Replace the deprecated `csurf` middleware with `csrf-csrf`** + ([#TBD](https://github.com/Ride-The-Lightning/RTL/pull/TBD), part of + [#1634](https://github.com/Ride-The-Lightning/RTL/issues/1634)). + `csurf` has been deprecated since 2022 and pins an old `cookie` release with a known + advisory; npm's only offered "fix" is a downgrade. It is now replaced by the maintained + `csrf-csrf` (v4), which implements the same double-submit-cookie pattern with an + HMAC-signed, session-bound token keyed on RTL's existing boot secret. The frontend + contract is unchanged — the token still arrives in the `XSRF-TOKEN` cookie/header and is + echoed back as `x-xsrf-token` (all header/body/query token sources csurf accepted are + still accepted), the signed token cookie keeps the `_csrf` name (now httpOnly), and the + `EBADCSRFTOKEN` error path in `app.ts` applies as before, so no Angular or Quickpay + changes were needed. One behavioral fix this surfaced: `app.ts` called `req.csrfToken()` + twice (cookie + header) — harmless under csurf, but token-desyncing under csrf-csrf — + and now generates the token once per request. Tokens are also now bound to the session, + so a token stolen from one session no longer validates in another — a check csurf's + cookie mode didn't perform (and the websocket upgrade check in `authCheck.ts` keeps its + previous semantics). Production `npm audit` drops from 6 low findings to 4, all in the + `crypto-browserify`/`elliptic` chain tracked in #1634. Verified against the docker + regtest fixture: both API suites (43 checks across LND, Core Lightning and Eclair) plus + a dedicated CSRF battery — valid-token auth, missing/garbage token → 403, + cross-session token replay → 403, token stability, the `XSRF-TOKEN` response header for + Quickpay, and the websocket handshake. + - **Rebuild the compiled CLN channels controller to match its source** ([#1631](https://github.com/Ride-The-Lightning/RTL/pull/1631)). The #1606 fix updated `server/controllers/cln/channels.ts` to mirror `peer_connected` onto the diff --git a/server/utils/app.ts b/server/utils/app.ts index 4c3c9e28..6ec67fda 100644 --- a/server/utils/app.ts +++ b/server/utils/app.ts @@ -59,8 +59,12 @@ export class ExpressApplication { this.app.use(this.common.baseHref + '/api/ecl', eclRoutes); this.app.use(this.common.baseHref, express.static(join(this.directoryName, '../..', 'frontend'))); this.app.use((req: any, res, next) => { - res.cookie('XSRF-TOKEN', req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''); // RTL Angular Frontend - res.setHeader('XSRF-TOKEN', req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''); // RTL Quickpay JQuery + // Generate the token once per request: with csrf-csrf every call mints a + // new token on a first visit, so calling twice would desync the cookie + // from the header and the _csrf cookie it must match. + const csrfToken = req.csrfToken ? req.csrfToken() : (req.cookies && req.cookies._csrf) ? req.cookies._csrf : ''; + res.cookie('XSRF-TOKEN', csrfToken); // RTL Angular Frontend + res.setHeader('XSRF-TOKEN', csrfToken); // RTL Quickpay JQuery res.sendFile(join(this.directoryName, '../..', 'frontend', 'index.html')); }); this.app.use((err, req, res, next) => { diff --git a/server/utils/authCheck.ts b/server/utils/authCheck.ts index 16306ef7..bc48f6fb 100644 --- a/server/utils/authCheck.ts +++ b/server/utils/authCheck.ts @@ -1,11 +1,11 @@ import jwt from 'jsonwebtoken'; -import csurf from 'csurf/index.js'; +import CSRF from './csrf.js'; import { Common, CommonService } from './common.js'; import { Logger, LoggerService } from './logger.js'; const common: CommonService = Common; const logger: LoggerService = Logger; -const csurfProtection = csurf({ cookie: true }); +const csurfProtection = CSRF.csrfProtection; export const isAuthenticated = (req, res, next) => { try { diff --git a/server/utils/csrf.ts b/server/utils/csrf.ts index 074a2dea..00680659 100644 --- a/server/utils/csrf.ts +++ b/server/utils/csrf.ts @@ -1,14 +1,31 @@ -import csurf from 'csurf/index.js'; +import { doubleCsrf } from 'csrf-csrf'; import { Application } from 'express'; import { Logger, LoggerService } from './logger.js'; import { Common, CommonService } from './common.js'; class CSRF { - public csrfProtection = csurf({ cookie: true }); public logger: LoggerService = Logger; public common: CommonService = Common; + // Signed double-submit-cookie protection (replaces the deprecated csurf). + // The signed token lives in the httpOnly '_csrf' cookie; the client echoes + // the same token (read from the XSRF-TOKEN cookie set in app.ts) in a + // header. The cookie is not secure-only because RTL commonly serves plain + // HTTP (matching the session cookie); token sources match what csurf + // accepted. The error code EBADCSRFTOKEN is handled in app.ts. + private doubleCsrfUtilities = doubleCsrf({ + getSecret: () => this.common.secret_key, + getSessionIdentifier: (req: any) => (req.session ? req.session.id : ''), + cookieName: '_csrf', + cookieOptions: { sameSite: 'strict', path: '/', secure: false, httpOnly: true }, + getCsrfTokenFromRequest: (req: any) => (req.body && req.body._csrf) || (req.query && req.query._csrf) || + req.headers['csrf-token'] || req.headers['xsrf-token'] || + req.headers['x-csrf-token'] || req.headers['x-xsrf-token'] + }); + + public csrfProtection = this.doubleCsrfUtilities.doubleCsrfProtection; + public mount(app: Application): Application { this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'CSRF', msg: 'Setting up CSRF..' }); if (process.env.NODE_ENV !== 'development') {