From 9428544d9959457e8e45a9060a85199470d89b18 Mon Sep 17 00:00:00 2001 From: Thomas Hintz Date: Fri, 7 Apr 2023 11:04:56 -0700 Subject: [PATCH] Test and sign in / create account improvements. --- cypress/e2e/createAccount.cy.js | 6 +-- src/app/(main)/rate-limited/page.jsx | 19 ++++++++ src/app/(main)/reactors/account/page.jsx | 3 +- .../(main)/reactors/create-account/page.jsx | 2 +- src/app/(main)/reactors/sign-in/page.jsx | 14 +----- src/lib/rateLimiter.js | 43 +++++++++++++++++++ src/pages/api/create-account.js | 42 ++---------------- src/pages/api/sign-in.js | 21 +++++++-- 8 files changed, 91 insertions(+), 59 deletions(-) create mode 100644 src/app/(main)/rate-limited/page.jsx create mode 100644 src/lib/rateLimiter.js diff --git a/cypress/e2e/createAccount.cy.js b/cypress/e2e/createAccount.cy.js index fd263e0..e7eb7a7 100644 --- a/cypress/e2e/createAccount.cy.js +++ b/cypress/e2e/createAccount.cy.js @@ -44,7 +44,7 @@ describe('Create Account Page', () => { cy.url().should('eq', `${Cypress.config().baseUrl}/reactors`) }); - it.only('should rate limit when sending multiple requests', () => { + it('should rate limit when sending multiple requests', () => { const testPassword = 'aVerySecureP@ssword123'; // A helper function to submit the create account form @@ -60,7 +60,7 @@ describe('Create Account Page', () => { cy.url().should('not.include', '/reactors/create-account'); cy.visit('/reactors/create-account?csi=cs_test_a1WLk3QvOyIJRFeV21BNIhdtXx26z5rF2x6pIzYKHq32ujVSz4W4fZ0IGI'); // Send requests up to the rate limit - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 8; i++) { submitForm(); cy.contains('Unexpected Error'); @@ -73,7 +73,7 @@ describe('Create Account Page', () => { submitForm(); // Check that the user is still on the create account page with a rate-limiting error message - cy.url().should('include', '/reactors/create-account'); + cy.url().should('include', '/rate-limited'); cy.contains('Too many requests.'); }); }); diff --git a/src/app/(main)/rate-limited/page.jsx b/src/app/(main)/rate-limited/page.jsx new file mode 100644 index 0000000..7d5eb6e --- /dev/null +++ b/src/app/(main)/rate-limited/page.jsx @@ -0,0 +1,19 @@ +import Link from 'next/link'; + +import { Container } from '@/components/Container'; +export const metadata = { + title: 'Rate Limited', + description: 'Rate limited.' +}; + +export default async function Page() { + return ( +
+ +

+ Rate Limited. Please Try Again Later. +

+
+
+ ); +} diff --git a/src/app/(main)/reactors/account/page.jsx b/src/app/(main)/reactors/account/page.jsx index 6de358c..49de08f 100644 --- a/src/app/(main)/reactors/account/page.jsx +++ b/src/app/(main)/reactors/account/page.jsx @@ -11,7 +11,8 @@ async function getSession() { if (!sessionId) { return false; } - const { user_id: userId } = await db.get('select user_id from sessions where session_id=?;', sessionId.value); + const dbRes = await db.get('select user_id from sessions where session_id=?;', sessionId.value); + const { user_id: userId } = dbRes ? dbRes : { user_id: false }; if (!userId) { return false; } diff --git a/src/app/(main)/reactors/create-account/page.jsx b/src/app/(main)/reactors/create-account/page.jsx index 2e9ec5a..cd5eb10 100644 --- a/src/app/(main)/reactors/create-account/page.jsx +++ b/src/app/(main)/reactors/create-account/page.jsx @@ -2,7 +2,7 @@ export const dynamic = 'force-dynamic'; import Link from 'next/link' import Stripe from 'stripe'; -const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL'); +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); import { dbRun } from '@/db'; import { XCircleIcon } from '@heroicons/react/20/solid' diff --git a/src/app/(main)/reactors/sign-in/page.jsx b/src/app/(main)/reactors/sign-in/page.jsx index 960de8b..e60d79e 100644 --- a/src/app/(main)/reactors/sign-in/page.jsx +++ b/src/app/(main)/reactors/sign-in/page.jsx @@ -2,8 +2,7 @@ export const dynamic = 'force-dynamic'; import Link from 'next/link' import Stripe from 'stripe'; -const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL'); -import { dbRun } from '@/db'; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); import { XCircleIcon } from '@heroicons/react/20/solid' @@ -17,10 +16,6 @@ export default async function Page({ searchParams }) { const csi = searchParams['csi']; const session = csi && await stripe.checkout.sessions.retrieve(csi); const email = (csi && session && session.customer_details.email) || searchParams['email']; - const message = searchParams['message']; - const submitted = email || message; - const valid = submitted && email && message; - let emailSentSuccessfully = false; if (unexpectedError) { return ( <> @@ -43,7 +38,7 @@ export default async function Page({ searchParams }) {

There was an error with your submission

-
+
{msg}
@@ -52,11 +47,6 @@ export default async function Page({ searchParams }) { )}
- Your Company

Sign in to your account

Or{' '} diff --git a/src/lib/rateLimiter.js b/src/lib/rateLimiter.js new file mode 100644 index 0000000..ad60280 --- /dev/null +++ b/src/lib/rateLimiter.js @@ -0,0 +1,43 @@ +const rateLimitWindow = 60 * 1000; // 1 minute +const maxRequests = 8; // Maximum number of requests within the rateLimitWindow +const rateLimiter = new Map(); + +const isRateLimited = (ip) => { + const currentTime = Date.now(); + const record = rateLimiter.get(ip); + + if (record) { + const [requestCount, windowStart] = record; + + if (currentTime - windowStart < rateLimitWindow) { + if (requestCount > maxRequests) { + return true; + } + rateLimiter.set(ip, [requestCount + 1, windowStart]); + } else { + rateLimiter.set(ip, [1, currentTime]); + } + } else { + rateLimiter.set(ip, [1, currentTime]); + } + + return false; +}; + +const withRateLimiter = (handler, redirect) => async (req, res) => { + const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + if (isRateLimited(ip)) { + if (redirect) { + res.redirect(`/rate-limited`); + } else { + res.status(429).json({ error: 'Too many requests. Please try again later.' }); + } + return; + } + + await handler(req, res); +}; + +module.exports = { + withRateLimiter, +}; diff --git a/src/pages/api/create-account.js b/src/pages/api/create-account.js index e52bbc6..c4f3cf5 100644 --- a/src/pages/api/create-account.js +++ b/src/pages/api/create-account.js @@ -2,6 +2,7 @@ import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); import db from '@/db'; +import { withRateLimiter } from '@/lib/rateLimiter'; import { scrypt, randomBytes, timingSafeEqual, randomUUID } from 'crypto'; import { promisify } from 'util'; @@ -44,47 +45,10 @@ const createSubscription = async (userId) => { await db.run('insert into subscriptions (uuid, user_id) values (?, ?);', randomUUID(), userId); }; -// Rate-limiting settings -const rateLimitWindow = 60 * 1000 * 3; // 3 minute -const maxRequests = 10; // Maximum number of requests within the rateLimitWindow -const rateLimiter = new Map(); - -const isRateLimited = (ip) => { - const currentTime = Date.now(); - const record = rateLimiter.get(ip); - - if (record) { - const [requestCount, windowStart] = record; - - // If the request is within the rate limit window, update the request count - if (currentTime - windowStart < rateLimitWindow) { - if (requestCount > maxRequests) { - return true; - } - rateLimiter.set(ip, [requestCount + 1, windowStart]); - } else { - // If the request is outside the rate limit window, reset the request count - rateLimiter.set(ip, [1, currentTime]); - } - } else { - // If the IP is not in the rateLimiter, add it - rateLimiter.set(ip, [1, currentTime]); - } - - return false; -}; - -export default async function handler(req, res) { +async function handler(req, res) { if (req.method === 'POST') { const { email, password, passwordagain, csi } = req.body; - // Check for rate limiting - const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; - if (isRateLimited(ip)) { - res.redirect(makeMsg(csi, email, 'Too many requests. Please try again later or send a message on the contact page if you believe this is an error.')); - return; - } - // Validate email, password, and csi if (email && password && password === passwordagain && csi) { // Check for minimum password length @@ -143,3 +107,5 @@ export default async function handler(req, res) { res.status(405).json({ error: 'Method not allowed. Only POST method is supported.' }); } } + +export default withRateLimiter(handler, true); diff --git a/src/pages/api/sign-in.js b/src/pages/api/sign-in.js index 1b8f71d..8ea44a6 100644 --- a/src/pages/api/sign-in.js +++ b/src/pages/api/sign-in.js @@ -1,10 +1,11 @@ import Stripe from 'stripe'; -const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL'); +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); import { setCookie } from 'cookies-next'; import { v4 as uuidv4 } from 'uuid'; import db from '@/db'; +import { withRateLimiter } from '@/lib/rateLimiter'; import { scrypt, randomBytes, timingSafeEqual } from 'crypto'; import { promisify } from 'util'; @@ -14,14 +15,24 @@ const scryptPromise = promisify(scrypt); async function verify(password, hash, salt, rounds = 64) { const keyBuffer = Buffer.from(hash, 'hex'); const derivedKey = await scryptPromise(password, salt, rounds); - return timingSafeEqual(keyBuffer, derivedKey); + + // Ensure both buffers have the same length + const keyBufferLength = keyBuffer.length; + const derivedKeyLength = derivedKey.length; + const maxLength = Math.max(keyBufferLength, derivedKeyLength); + const paddedKeyBuffer = keyBuffer.length < maxLength ? + Buffer.concat([Buffer.alloc(maxLength - keyBufferLength), keyBuffer]) : keyBuffer; + const paddedDerivedKey = derivedKey.length < maxLength ? + Buffer.concat([Buffer.alloc(maxLength - derivedKeyLength), derivedKey]) : derivedKey; + + return timingSafeEqual(paddedKeyBuffer, paddedDerivedKey); } function makeMsg(email, text) { return `/reactors/sign-in?msg=${encodeURIComponent(text)}&email=${encodeURIComponent(email)}` }; -export default async function handler(req, res) { +async function handler(req, res) { if (req.method === 'POST') { const { email, password, remember_me: rememberMe } = req.body; if (email && password) { @@ -48,6 +59,8 @@ export default async function handler(req, res) { } } } else { - // Handle any other HTTP method + res.status(405).json({ error: 'Method not allowed. Only POST method is supported.' }); } } + +export default withRateLimiter(handler, true);