From a2e4d86064cc55427779e49a6c95d1a3e8c70a55 Mon Sep 17 00:00:00 2001 From: bc-chaz Date: Thu, 24 Jun 2021 15:51:40 -0700 Subject: [PATCH] feat(common): LFG-177 corrects IDOR --- .env-sample | 3 +-- README.md | 3 +-- context/session.tsx | 4 ++-- lib/auth.ts | 16 +++++++++++++--- lib/hooks.ts | 16 ++++++++-------- pages/api/auth.ts | 5 +++-- pages/api/load.ts | 6 ++++-- pages/index.tsx | 6 +++--- pages/products/[pid].tsx | 4 ++-- types/data.ts | 4 ++-- 10 files changed, 39 insertions(+), 28 deletions(-) diff --git a/.env-sample b/.env-sample index 43100d0b..7834836d 100644 --- a/.env-sample +++ b/.env-sample @@ -9,9 +9,8 @@ CLIENT_SECRET={app secret} AUTH_CALLBACK=https://{ngrok_id}.ngrok.io/api/auth -# Set cookie variables, replace jwt key with a 32+ random character secret +# Replace jwt key with a 32+ random character secret -COOKIE_NAME=__{NAME} JWT_KEY={SECRET} # Specify the type of database diff --git a/README.md b/README.md index 27239dba..f4e4e8cf 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ To get the app running locally, follow these instructions: - If deploying on Heroku, skip `.env` setup. Instead, enter `env` variables in the Heroku App Dashboard under `Settings -> Config Vars`. 6. [Replace client_id and client_secret in .env](https://devtools.bigcommerce.com/my/apps) (from `View Client ID` in the dev portal). 7. Update AUTH_CALLBACK in `.env` with the `ngrok_id` from step 5. -8. Enter a cookie name, as well as a jwt secret in `.env`. - - The cookie name should be unique +8. Enter a jwt secret in `.env`. - JWT key should be at least 32 random characters (256 bits) for HS256 9. Specify DB_TYPE in `.env` - If using Firebase, enter your firebase config keys. See [Firebase quickstart](https://firebase.google.com/docs/firestore/quickstart) diff --git a/context/session.tsx b/context/session.tsx index 41544a22..6b41e6f5 100644 --- a/context/session.tsx +++ b/context/session.tsx @@ -4,8 +4,8 @@ import { ContextValues } from '../types'; const SessionContext = createContext>({}); const SessionProvider = ({ children }) => { - const [storeHash, setStoreHash] = useState(''); - const value = { storeHash, setStoreHash }; + const [context, setContext] = useState(''); + const value = { context, setContext }; return ( diff --git a/lib/auth.ts b/lib/auth.ts index 25c178a9..218101a3 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,9 +1,10 @@ +import * as jwt from 'jsonwebtoken'; import { NextApiRequest, NextApiResponse } from 'next'; import * as BigCommerce from 'node-bigcommerce'; import { QueryParams, SessionProps } from '../types'; import db from './db'; -const { AUTH_CALLBACK, CLIENT_ID, CLIENT_SECRET } = process.env; +const { AUTH_CALLBACK, CLIENT_ID, CLIENT_SECRET, JWT_KEY } = process.env; // Create BigCommerce instance // https://github.com/getconversio/node-bigcommerce @@ -48,9 +49,10 @@ export async function setSession(session: SessionProps) { export async function getSession({ query: { context = '' } }: NextApiRequest) { if (typeof context !== 'string') return; - const accessToken = await db.getStoreToken(context); + const decodedContext = decodePayload(context)?.context; + const accessToken = await db.getStoreToken(decodedContext); - return { accessToken, storeHash: context }; + return { accessToken, storeHash: decodedContext }; } export async function removeSession(res: NextApiResponse, session: SessionProps) { @@ -60,3 +62,11 @@ export async function removeSession(res: NextApiResponse, session: SessionProps) export async function removeUserData(res: NextApiResponse, session: SessionProps) { await db.deleteUser(session); } + +export function encodePayload(context: string) { + return jwt.sign({ context }, JWT_KEY, { expiresIn: '24h' }); +} + +export function decodePayload(encodedContext: string) { + return jwt.verify(encodedContext, JWT_KEY); +} diff --git a/lib/hooks.ts b/lib/hooks.ts index 89b2f633..0a0fe044 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -2,16 +2,16 @@ import useSWR from 'swr'; import { useSession } from '../context/session'; import { ListItem } from '../types'; -function fetcher(url: string, storeHash: string) { - return fetch(`${url}?context=${storeHash}`).then(res => res.json()); +function fetcher(url: string, encodedContext: string) { + return fetch(`${url}?context=${encodedContext}`).then(res => res.json()); } // Reusable SWR hooks // https://swr.vercel.app/ export function useProducts() { - const storeHash = useSession()?.storeHash; + const encodedContext = useSession()?.context; // Request is deduped and cached; Can be shared across components - const { data, error } = useSWR(storeHash ? ['/api/products', storeHash] : null, fetcher); + const { data, error } = useSWR(encodedContext ? ['/api/products', encodedContext] : null, fetcher); return { summary: data, @@ -21,9 +21,9 @@ export function useProducts() { } export function useProductList() { - const storeHash = useSession()?.storeHash; + const encodedContext = useSession()?.context; // Use an array to send multiple arguments to fetcher - const { data, error, mutate: mutateList } = useSWR(storeHash ? ['/api/products/list', storeHash] : null, fetcher); + const { data, error, mutate: mutateList } = useSWR(encodedContext ? ['/api/products/list', encodedContext] : null, fetcher); return { list: data, @@ -34,10 +34,10 @@ export function useProductList() { } export function useProductInfo(pid: number, list: ListItem[]) { - const storeHash = useSession()?.storeHash; + const encodedContext = useSession()?.context; const product = list.find(item => item.id === pid); // Conditionally fetch product if it doesn't exist in the list (e.g. deep linking) - const { data, error } = useSWR(!product && storeHash ? [`/api/products/${pid}`, storeHash] : null, fetcher); + const { data, error } = useSWR(!product && encodedContext ? [`/api/products/${pid}`, encodedContext] : null, fetcher); return { product: product ?? data, diff --git a/pages/api/auth.ts b/pages/api/auth.ts index fa61e5d4..f21676ad 100644 --- a/pages/api/auth.ts +++ b/pages/api/auth.ts @@ -1,14 +1,15 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { getBCAuth, setSession } from '../../lib/auth'; +import { encodePayload, getBCAuth, setSession } from '../../lib/auth'; export default async function auth(req: NextApiRequest, res: NextApiResponse) { try { // Authenticate the app on install const session = await getBCAuth(req.query); const storeHash = session?.context?.split('/')[1] || ''; + const encodedContext = encodePayload(storeHash); // Signed JWT to validate/ prevent tampering await setSession(session); - res.redirect(302, `/?context=${storeHash}`); + res.redirect(302, `/?context=${encodedContext}`); } catch (error) { const { message, response } = error; res.status(response?.status || 500).json(message); diff --git a/pages/api/load.ts b/pages/api/load.ts index 406f062f..b1fd5bee 100644 --- a/pages/api/load.ts +++ b/pages/api/load.ts @@ -1,13 +1,15 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { getBCVerify, setSession } from '../../lib/auth'; +import { encodePayload, getBCVerify, setSession } from '../../lib/auth'; export default async function load(req: NextApiRequest, res: NextApiResponse) { try { + // Verify when app loaded (launch) const session = await getBCVerify(req.query); const storeHash = session?.context?.split('/')[1] || ''; + const encodedContext = encodePayload(storeHash); // Signed JWT to validate/ prevent tampering await setSession(session); - res.redirect(302, `/?context=${storeHash}`); + res.redirect(302, `/?context=${encodedContext}`); } catch (error) { const { message, response } = error; res.status(response?.status || 500).json(message); diff --git a/pages/index.tsx b/pages/index.tsx index caac59ec..aea97b9b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -7,11 +7,11 @@ import { useProducts } from '../lib/hooks'; const Index = ({ context }: { context: string }) => { const { isLoading, summary } = useProducts(); - const { setStoreHash } = useSession(); + const { setContext } = useSession(); useEffect(() => { - if (context) setStoreHash(context); - }, [context, setStoreHash]); + if (context) setContext(context); + }, [context, setContext]); if (isLoading) return ; diff --git a/pages/products/[pid].tsx b/pages/products/[pid].tsx index 42569ca7..4e8721ea 100644 --- a/pages/products/[pid].tsx +++ b/pages/products/[pid].tsx @@ -8,7 +8,7 @@ import { FormData } from '../../types'; const ProductInfo = () => { const router = useRouter(); - const { storeHash } = useSession(); + const encodedContext = useSession()?.context; const pid = Number(router.query?.pid); const { isError, isLoading, list = [], mutateList } = useProductList(); const { isLoading: isInfoLoading, product } = useProductInfo(pid, list); @@ -24,7 +24,7 @@ const ProductInfo = () => { mutateList([...filteredList, { ...product, ...data }], false); // Update product details - await fetch(`/api/products/${pid}?context=${storeHash}`, { + await fetch(`/api/products/${pid}?context=${encodedContext}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), diff --git a/types/data.ts b/types/data.ts index 5e46cea0..88568eca 100644 --- a/types/data.ts +++ b/types/data.ts @@ -1,6 +1,6 @@ export interface ContextValues { - storeHash: string; - setStoreHash: (key: string) => void; + context: string; + setContext: (key: string) => void; } export interface FormData {