From 687f97e03c9dbaa16711074259ad50af68f8fd5f Mon Sep 17 00:00:00 2001 From: mhanson <3117293+mhanson-github@users.noreply.github.com> Date: Sun, 8 Sep 2024 23:01:25 -0400 Subject: [PATCH] PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF Implement CSRF check PRI-46: Implement CSRF check PRI-46: Implement CSRF check PRI-46: Implement CSRF check PRI-46: Implement CSRF check PRI-46: Implement CSRF check Implement CSRF check PRI-46: Implementing CSRF Token Implementing CSRF Token PRI-46: Implementing CSRF Token Implementing CSRF Token PRI-46: Implementing CSRF Token Implementing CSRF Token --- api/api.ts | 5 +++-- api/auth.ts | 6 ++++-- api/csrf.ts | 30 ++++++++++++++++++++++++++++++ api/handlers/node.ts | 15 ++++++++------- api/index.ts | 2 ++ hooks/fetcher.ts | 27 +++++++++++++++++++++++---- package-lock.json | 9 +++++++++ package.json | 1 + services/auth.service.ts | 16 +++++++++++++++- 9 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 api/csrf.ts diff --git a/api/api.ts b/api/api.ts index d768942..c95c1fa 100644 --- a/api/api.ts +++ b/api/api.ts @@ -7,6 +7,7 @@ import { Request, Response } from 'express'; import asyncRouteHandler from './handlers/async-router-handler' import { fetchWithTimeout } from "./handlers/util"; const yaml = require('js-yaml') +import { doubleCsrfProtection } from './csrf'; const ACCOUNT_INFO_URL = process.env.ACCOUNT_INFO_URL ?? "https://explorer-atomium.shardeum.org/api/account"; // const FAUCET_CLAIM_URL = @@ -70,7 +71,7 @@ apiRouter.get('/node/performance', (req, res) => { ]) }) -apiRouter.post('/log/stake', (req, res) => { +apiRouter.post('/log/stake', doubleCsrfProtection, (req, res) => { console.log('Writing Stake TX logs') fs.appendFile(path.join(__dirname, '../stakeTXs.log'), JSON.stringify(req.body, undefined, 3), err => { if (err) { @@ -84,7 +85,7 @@ apiRouter.post('/log/stake', (req, res) => { res.status(200).json({ status: 'ok' }) }) -apiRouter.post('/log/unstake', (req, res) => { +apiRouter.post('/log/unstake', doubleCsrfProtection, (req, res) => { console.log('Writing Unstake TX logs') fs.appendFile(path.join(__dirname, '../unstakeTXs.log'), JSON.stringify(req.body, undefined, 3), err => { if (err) { diff --git a/api/auth.ts b/api/auth.ts index fa16f7c..85a8680 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -5,6 +5,7 @@ import * as crypto from '@shardus/crypto-utils'; import rateLimit from 'express-rate-limit'; const yaml = require('js-yaml') const jwt = require('jsonwebtoken') +import { doubleCsrfProtection } from './csrf'; function isValidSecret(secret: unknown) { return typeof secret === 'string' && secret.length >= 32; @@ -19,7 +20,7 @@ const jwtSecret = (isValidSecret(process.env.JWT_SECRET)) : generateRandomSecret(); crypto.init('64f152869ca2d473e4ba64ab53f49ccdb2edae22da192c126850970e788af347'); -export const loginHandler = (req: Request, res: Response) => { +export const loginHandler = [doubleCsrfProtection,(req: Request, res: Response) => { const password = req.body && req.body.password const hashedPass = crypto.hash(password); // Exec the CLI validator login command @@ -49,10 +50,11 @@ export const loginHandler = (req: Request, res: Response) => { res.send({ status: 'ok' }) }) console.log('executing operator-cli gui login...') -} +}] export const logoutHandler = (req: Request, res: Response) => { res.clearCookie("accessToken"); + res.clearCookie("csrfToken"); res.send({ status: 'ok' }) } diff --git a/api/csrf.ts b/api/csrf.ts new file mode 100644 index 0000000..57603c9 --- /dev/null +++ b/api/csrf.ts @@ -0,0 +1,30 @@ +import { doubleCsrf, type DoubleCsrfCookieOptions } from 'csrf-csrf'; +import * as crypto from '@shardus/crypto-utils'; +import express, { Request, Response, NextFunction } from 'express' + +crypto.init('64f152869ca2d473e4ba64ab53f49ccdb2edae22da192c126850970e788af347'); + +const csrfSecret = Buffer.from(crypto.randomBytes(32)).toString('hex'); +const cookieOptions: DoubleCsrfCookieOptions = { + maxAge: 24 * 60 * 60 * 1000, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/' +}; + +export const { doubleCsrfProtection, generateToken } = doubleCsrf({ + size: 4 * 8, + getSecret: () => csrfSecret, + cookieName: 'csrfToken', + cookieOptions, +}); + +export const generateTokenHandler = (req: Request, res: Response) => { + const generatedToken = generateToken(req, res); + res.set('Content-Type', 'text/plain'); + if (!generatedToken) { + return res.status(500).send('Cannot generate the requested content.'); + } + + return res.send(generatedToken); +} diff --git a/api/handlers/node.ts b/api/handlers/node.ts index fa65ac7..40b9c4c 100644 --- a/api/handlers/node.ts +++ b/api/handlers/node.ts @@ -16,6 +16,7 @@ import { existsSync } from 'fs'; import asyncRouteHandler from './async-router-handler'; import fs from 'fs'; import * as crypto from '@shardus/crypto-utils'; +import { doubleCsrfProtection } from '../csrf'; const yaml = require('js-yaml') @@ -29,14 +30,14 @@ export const nodeVersionHandler = asyncRouteHandler(async (req: Request, res: Re export default function configureNodeHandlers(apiRouter: Router) { let lastActiveNodeState: NodeStatus; - apiRouter.post('/node/start', asyncRouteHandler(async (req: Request, res: Response) => { + apiRouter.post('/node/start', doubleCsrfProtection, asyncRouteHandler(async (req: Request, res: Response) => { // Exec the CLI validator start command console.log('executing operator-cli start...'); execFileSync('operator-cli', ['start']); res.status(200).json({ status: "ok" }) })); - apiRouter.post('/node/stop', asyncRouteHandler(async (req: Request, res: Response) => { + apiRouter.post('/node/stop', doubleCsrfProtection, asyncRouteHandler(async (req: Request, res: Response) => { // Exec the CLI validator stop command console.log('executing operator-cli stop...'); execFileSync('operator-cli', ['stop', '-f']) @@ -90,8 +91,8 @@ export default function configureNodeHandlers(apiRouter: Router) { }); })); - apiRouter.delete('/node/logs', asyncRouteHandler(async (req: Request, res: Response) => { - let logsPath = path.join(__dirname, '../../../cli/build/logs'); + apiRouter.delete('/node/logs', doubleCsrfProtection, asyncRouteHandler(async (req: Request, res: Response) => { + let logsPath = path.join(__dirname, '../../../validator-cli/build/logs'); if (!existsSync(logsPath)) { res.json({ logsCleared: [] }); return; @@ -135,7 +136,7 @@ export default function configureNodeHandlers(apiRouter: Router) { ); apiRouter.post( - '/node/update', + '/node/update', doubleCsrfProtection, asyncRouteHandler(async (req: Request, res: Response) => { const outUpdate = execFileSync('operator-cli', ['update']); console.log('operator-cli update: ', outUpdate); @@ -158,7 +159,7 @@ export default function configureNodeHandlers(apiRouter: Router) { })); apiRouter.post( - '/password', + '/password', doubleCsrfProtection, asyncRouteHandler(async (req: Request<{ currentPassword: string; newPassword: string; @@ -189,7 +190,7 @@ export default function configureNodeHandlers(apiRouter: Router) { } - apiRouter.post('/settings', asyncRouteHandler(async (req: Request, res: Response) => { + apiRouter.post('/settings', doubleCsrfProtection, asyncRouteHandler(async (req: Request, res: Response) => { if (!req.body) { badRequestResponse(res, 'Invalid body'); return; diff --git a/api/index.ts b/api/index.ts index 205994b..14bc2a6 100644 --- a/api/index.ts +++ b/api/index.ts @@ -9,6 +9,7 @@ import dotenv from 'dotenv'; import { cacheStaticFiles, preventBrowserCacheForDynamicContent, setSecurityHeaders } from './security-headers'; import { errorMiddleware } from './error-middleware'; import { nodeVersionHandler } from './handlers/node'; +import { generateTokenHandler } from './csrf'; dotenv.config() const port = process.env.PORT ? +process.env.PORT : 8081 @@ -41,6 +42,7 @@ if (isDev) { app.use(apiLimiter) app.use(cookieParser()); setSecurityHeaders(app); + app.get('/csrf-token', generateTokenHandler) app.post('/auth/login', loginHandler) app.post('/auth/logout', logoutHandler) app.use('/api', jwtMiddleware, apiRouter) diff --git a/hooks/fetcher.ts b/hooks/fetcher.ts index 8b9138b..8587405 100644 --- a/hooks/fetcher.ts +++ b/hooks/fetcher.ts @@ -2,14 +2,33 @@ import { authService } from '../services/auth.service' import { useGlobals } from '../utils/globals' const { apiBase } = useGlobals() +export async function getCsrfToken(): Promise { + const response = await fetch(`/csrf-token`, { + mode: 'cors', + signal: AbortSignal.timeout(2000), + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('Token was not received.'); + } -export const fetcher = (input: RequestInfo | URL, + return await response.text(); +} +const unsafeMethods = ['POST', 'PUT', 'PATCH', 'DELETE']; +export const fetcher = async (input: RequestInfo | URL, init: RequestInit, showErrorMessage: (msg: string) => void): Promise => { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + } + const isUnsafeMethod = unsafeMethods.includes(init?.method ?? ''); + if (isUnsafeMethod) { + const csrfToken = await getCsrfToken(); + headers['X-Csrf-Token'] = csrfToken; + } return fetch(input, { - headers: { - 'Content-Type': 'application/json', - }, + headers: headers, credentials: 'include', // Send cookies ...(init ?? {}), }).then(async (res) => { diff --git a/package-lock.json b/package-lock.json index 3f71c6c..a5b33d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@walletconnect/modal": "^2.6.2", "chart.js": "4.3.0", "cookie-parser": "^1.4.6", + "csrf-csrf": "^3.0.6", "daisyui": "2.52.0", "dotenv": "16.3.1", "ethers": "5.7.2", @@ -4139,6 +4140,14 @@ "node": ">= 8" } }, + "node_modules/csrf-csrf": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-3.0.6.tgz", + "integrity": "sha512-FkeKRBhtVhA9rOo9dwU/nHePVtCCsVgQj+OIw/c4BINrjqPWTWuBEd+iB4wfPVuC879OhUqdyPLCRJnAZEX4fQ==", + "dependencies": { + "http-errors": "^2.0.0" + } + }, "node_modules/css-selector-tokenizer": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", diff --git a/package.json b/package.json index cabd702..0657c1b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@walletconnect/modal": "^2.6.2", "chart.js": "4.3.0", "cookie-parser": "^1.4.6", + "csrf-csrf": "^3.0.6", "daisyui": "2.52.0", "dotenv": "16.3.1", "ethers": "5.7.2", diff --git a/services/auth.service.ts b/services/auth.service.ts index 06bbea8..d38dc2b 100644 --- a/services/auth.service.ts +++ b/services/auth.service.ts @@ -5,12 +5,26 @@ const isLoggedInKey = 'isLoggedIn' export const wasLoggedOutKey = 'wasLoggedOut' export const isFirstTimeUserKey = 'isFirstTimeUser' +export async function getCsrfToken(): Promise { + document.cookie = "csrf-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + const response = await fetch(`/csrf-token`, { + signal: AbortSignal.timeout(2000), + }); + + if (!response.ok) { + throw new Error('Token was not received.'); + } + + return await response.text(); +} const login = async (apiBase: string, password: string) => { const sha256digest = await hashSha256(password); + const csrfToken = await getCsrfToken(); const res = await fetch(`${apiBase}/auth/login`, { - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json' , 'X-Csrf-Token': csrfToken}, method: 'POST', body: JSON.stringify({ password: sha256digest }), + credentials: 'include', }); await res.json(); if (!res.ok) {