diff --git a/keyserver/src/responders/landing-handler.js b/keyserver/src/responders/landing-handler.js index 9eb2d66b2..dd99aa2ff 100644 --- a/keyserver/src/responders/landing-handler.js +++ b/keyserver/src/responders/landing-handler.js @@ -1,183 +1,195 @@ // @flow import html from 'common-tags/lib/html'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import * as React from 'react'; import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; +import { isValidSIWENonce } from 'lib/utils/siwe-utils.js'; + import { type LandingSSRProps } from '../landing/landing-ssr.react'; import { waitForStream } from '../utils/json-stream'; import { getAndAssertLandingURLFacts } from '../utils/urls'; import { getMessageForException } from './utils'; async function landingHandler(req: $Request, res: $Response) { try { await landingResponder(req, res); } catch (e) { console.warn(e); if (!res.headersSent) { res.status(500).send(getMessageForException(e)); } } } const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&family=IBM+Plex+Sans:wght@400;500&display=swap'; const iaDuoFontsURL = 'fonts/duo.css'; const localFontsURL = 'fonts/local-fonts.css'; async function getDevFontURLs(): Promise<$ReadOnlyArray> { try { await access(localFontsURL); return [localFontsURL, iaDuoFontsURL]; } catch { return [googleFontsURL, iaDuoFontsURL]; } } type AssetInfo = { +jsURL: string, +fontURLs: $ReadOnlyArray, +cssInclude: string, }; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontURLs = await getDevFontURLs(); assetInfo = { jsURL: 'http://localhost:8082/dev.build.js', fontURLs, cssInclude: '', }; return assetInfo; } try { const assetsString = await readFile('../landing/dist/assets.json', 'utf8'); const assets = JSON.parse(assetsString); assetInfo = { jsURL: `compiled/${assets.browser.js}`, fontURLs: [googleFontsURL, iaDuoFontsURL], cssInclude: html` `, }; return assetInfo; } catch { throw new Error( 'Could not load assets.json for landing build. ' + 'Did you forget to run `yarn dev` in the landing folder?', ); } } type LandingApp = React.ComponentType; let webpackCompiledRootComponent: ?LandingApp = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe landing/dist doesn't always exist const webpackBuild = await import('landing/dist/landing.build.cjs'); webpackCompiledRootComponent = webpackBuild.default.default; return webpackCompiledRootComponent; } catch { throw new Error( 'Could not load landing.build.cjs. ' + 'Did you forget to run `yarn dev` in the landing folder?', ); } } const { renderToNodeStream } = ReactDOMServer; async function landingResponder(req: $Request, res: $Response) { const siweNonce = req.header('siwe-nonce'); + if ( + siweNonce !== null && + siweNonce !== undefined && + !isValidSIWENonce(siweNonce) + ) { + res.status(400).send({ + message: 'Invalid nonce in siwe-nonce header.', + }); + return; + } const [{ jsURL, fontURLs, cssInclude }, LandingSSR] = await Promise.all([ getAssetInfo(), getWebpackCompiledRootComponentForSSR(), ]); const fontsInclude = fontURLs .map(url => ``) .join(''); const urlFacts = getAndAssertLandingURLFacts(); const { basePath } = urlFacts; // prettier-ignore res.write(html` Comm ${fontsInclude} ${cssInclude}
`); // We remove trailing slash for `react-router` const routerBasename = basePath.replace(/\/$/, ''); const clientPath = routerBasename + req.url; const reactStream = renderToNodeStream( , ); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); const siweNonceString = siweNonce ? `"${siweNonce}"` : 'null'; // prettier-ignore res.end(html`
`); } export default landingHandler; diff --git a/lib/utils/siwe-utils.js b/lib/utils/siwe-utils.js new file mode 100644 index 000000000..a4a8c91e4 --- /dev/null +++ b/lib/utils/siwe-utils.js @@ -0,0 +1,8 @@ +// @flow + +const siweNonceRegex: RegExp = /^[a-zA-Z0-9]{17}$/; +function isValidSIWENonce(candidate: string): boolean { + return siweNonceRegex.test(candidate); +} + +export { isValidSIWENonce }; diff --git a/lib/utils/siwe-utils.test.js b/lib/utils/siwe-utils.test.js new file mode 100644 index 000000000..25ceecae4 --- /dev/null +++ b/lib/utils/siwe-utils.test.js @@ -0,0 +1,78 @@ +// @flow + +import { isValidSIWENonce } from './siwe-utils.js'; + +describe('SIWE Nonce utils', () => { + it('isValidSIWENonce should match valid nonces', () => { + // Following valid nonces generated using `siwe/generateNonce()` function + const validNonces = [ + '1VxXbANmmHLFP4eks', + '2MmJuAAseMtLv5mCn', + '2USPurs7uBwua8S8x', + '3Qk22CmMo65LpaG9c', + '4QXu7RFVlXreNzQrK', + '5BLyDYtuk7coJCvzC', + '7K2JD4wCmGrKsTkOF', + 'Akx19qnKDuvB48SZC', + 'aTirbrzPTKVPOCl4D', + 'bkjdjOg4tA7Xpy452', + 'BNhdFo59dEribfobg', + 'buf0Wxv4bGLjcWT3c', + 'CoXCHoJTCdVy5sTtf', + 'd7oetTb1wJuvhCA4l', + 'DMaRtZ6yiLRnuyafK', + 'e7Sdl1z6EQiXCN8l9', + 'EF4Hdiej0gxDmBjvy', + 'EFauh8CSAIRlDwOLg', + 'eqbMxIOjiJdjskqlN', + 'fOYFxCD5ir430agxl', + 'GCN1lI61eRvHUws1M', + 'gGDMKiPcbykhCwzMO', + 'gJCSvNZ1pHksy5TpJ', + 'gzgnURrK65KTlfYBp', + 'H4wZ6w5qiisbulWzI', + 'hNJFuzAdnSEU4bx8X', + 'HWaO4nN9aDGH8AnaA', + 'IU5DJWa9TUXz5H1tV', + 'kDE8OPvsheXIihCj4', + 'LaQ8i3ZJY3DpdwCPI', + 'LBWHU6XM4MFjLqXrd', + 'lf3hoCBuqTdsl58EA', + 'm1nx1X4EQJRL3Sg9b', + 'Mk6t3PKZnL8jwcd0n', + 'mnkumoUJFtI9Zhxdu', + 'OPL9f2NvQL3d8rHce', + 'orJqvnFu0dsIDuPPv', + 'ox9zchmtBRDUxhiKr', + 'q35RUCfsoGHszDi2W', + 'qc13k7CZp9noVr9Xm', + 'QnyXVsOu4ul4E8UTT', + 'RBDJVgUWxrxVoOYpg', + 'u3vxhrCwHihNlwZOf', + 'wBF0fzrjy4oWrager', + 'xEmvoMx72izf48yKN', + 'Xha2QfepadZtolkTi', + 'XZLvZW0VqcK3og31l', + 'yGMxs0bt7r15KxAFF', + 'YmHPiTCKGzTMyWe3x', + 'YzxFDqTd84pTDbcJP', + ]; + + validNonces.forEach(nonce => { + expect(isValidSIWENonce(nonce)).toBe(true); + }); + }); + + it('isValidSIWENonce should fail if nonce is wrong length', () => { + const shortNonce = '1VxXbANmmHLFP4ek'; + expect(isValidSIWENonce(shortNonce)).toBe(false); + + const longNonce = '1VxXbANmmHLFP4eks1'; + expect(isValidSIWENonce(longNonce)).toBe(false); + }); + + it('isValidSIWENonce should fail if nonce has invalid characters', () => { + const invalidNonce = '1VxXbANmmHLFP4ek!'; + expect(isValidSIWENonce(invalidNonce)).toBe(false); + }); +});