├── eslint.config.js ├── .env.production ├── public └── favicon.ico ├── 5-secrets-exposure.png ├── .env.example ├── 4-missing-authorization.png ├── 6-cross-site-scripting.png ├── 2-missing-authentication.png ├── renovate.json ├── stylelint.config.mjs ├── next-env.d.ts ├── app ├── global.scss ├── example-5-secrets-exposure │ ├── solution-2 │ │ ├── page.tsx │ │ └── SecretsExposure.tsx │ ├── vulnerable │ │ ├── page.tsx │ │ └── SecretsExposure.tsx │ ├── solution-1 │ │ └── page.tsx │ └── common.tsx ├── example-2-missing-authentication-server-component │ ├── vulnerable │ │ └── page.tsx │ ├── solution-1 │ │ └── page.tsx │ ├── solution-2 │ │ └── page.tsx │ └── common.tsx ├── example-3-missing-authorization-route-handler │ ├── [exampleType] │ │ ├── page.tsx │ │ └── MissingAuthorizationApiRoute.tsx │ └── vulnerable-2 │ │ ├── page.tsx │ │ └── MissingAuthorizationApiRoute.tsx ├── example-1-missing-authentication-route-handler │ └── [exampleType] │ │ ├── page.tsx │ │ └── MissingAuthenticationApiRoute.tsx ├── example-4-missing-authorization-server-component │ ├── vulnerable-1 │ │ ├── page.tsx │ │ └── MissingAuthorizationServerComponent.tsx │ ├── vulnerable-2 │ │ ├── page.tsx │ │ └── MissingAuthorizationServerComponent.tsx │ ├── solution-1 │ │ └── page.tsx │ ├── solution-2 │ │ └── page.tsx │ └── common.tsx ├── api │ ├── example-1-missing-authentication-route-handler │ │ ├── vulnerable │ │ │ └── route.ts │ │ ├── solution-1 │ │ │ └── route.ts │ │ └── solution-2 │ │ │ └── route.ts │ ├── example-3-missing-authorization-route-handler │ │ ├── solution-1 │ │ │ └── route.ts │ │ ├── vulnerable-1 │ │ │ └── route.ts │ │ └── solution-2 │ │ │ └── route.ts │ └── example-5-secrets-exposure │ │ └── solution-2 │ │ └── route.ts ├── LinkIfNotCurrent.tsx ├── example-6-cross-site-scripting │ ├── solution-1 │ │ └── page.tsx │ ├── vulnerable │ │ └── page.tsx │ ├── solution-2 │ │ └── page.tsx │ ├── solution-3 │ │ └── page.tsx │ └── common.tsx ├── (auth) │ ├── logout │ │ └── route.ts │ ├── login │ │ ├── page.tsx │ │ └── LoginForm.tsx │ └── api │ │ └── login │ │ └── route.ts ├── page.tsx └── layout.tsx ├── next.config.js ├── fly.toml ├── .vscode └── settings.json ├── tsconfig.json ├── .gitignore ├── migrations ├── 002-create-table-sessions.ts ├── 001-create-table-users.ts └── 003-create-table-blog-posts.ts ├── .dockerignore ├── util ├── validation.ts └── cookies.ts ├── prettier.config.mjs ├── database ├── connect.ts ├── sessions.ts ├── users.ts └── blogPosts.ts ├── package.json ├── ley.config.js ├── Dockerfile ├── scripts └── fly-io-start.sh ├── .github └── workflows │ └── lint-check-types-build-deploy.yml └── readme.md /eslint.config.js: -------------------------------------------------------------------------------- 1 | export { default } from 'eslint-config-upleveled'; 2 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # Public environment variables is ok they are on GitHub 2 | FLY_IO=true 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gruvector/security-vulnerability/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /5-secrets-exposure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gruvector/security-vulnerability/HEAD/5-secrets-exposure.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | API_KEY=XXXXXXX 2 | PGHOST=XXXXXXX 3 | PGDATABASE=XXXXXXX 4 | PGUSERNAME=XXXXXXX 5 | PGPASSWORD=XXXXXXX 6 | -------------------------------------------------------------------------------- /4-missing-authorization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gruvector/security-vulnerability/HEAD/4-missing-authorization.png -------------------------------------------------------------------------------- /6-cross-site-scripting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gruvector/security-vulnerability/HEAD/6-cross-site-scripting.png -------------------------------------------------------------------------------- /2-missing-authentication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gruvector/security-vulnerability/HEAD/2-missing-authentication.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>karlhorky/renovate-config:default.json5"] 4 | } 5 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('stylelint').Config} */ 2 | const config = { 3 | extends: ['stylelint-config-upleveled'], 4 | }; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /app/global.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | color: var(--main-text-color); 9 | } 10 | 11 | header > nav { 12 | &, 13 | > div:last-child { 14 | display: flex; 15 | gap: 15px; 16 | } 17 | 18 | > div:last-child { 19 | margin-left: auto; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/example-5-secrets-exposure/solution-2/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUsers } from '../../../database/users'; 2 | import SecretsExposure from './SecretsExposure'; 3 | 4 | export const dynamic = 'force-dynamic'; 5 | 6 | export default async function SecretsExposurePage() { 7 | const users = await getUsers(); 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | serverComponentsExternalPackages: ['canvas', 'jsdom'], 6 | typedRoutes: true, 7 | }, 8 | eslint: { 9 | ignoreDuringBuilds: true, 10 | }, 11 | typescript: { 12 | ignoreBuildErrors: true, 13 | }, 14 | }; 15 | 16 | export default nextConfig; 17 | -------------------------------------------------------------------------------- /app/example-2-missing-authentication-server-component/vulnerable/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPublishedBlogPosts } from '../../../database/blogPosts'; 2 | import Common from '../common'; 3 | 4 | export const dynamic = 'force-dynamic'; 5 | 6 | export default async function MissingAuthenticationServerComponentPage() { 7 | const blogPosts = await getPublishedBlogPosts(); 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /app/example-3-missing-authorization-route-handler/[exampleType]/page.tsx: -------------------------------------------------------------------------------- 1 | import MissingAuthorizationApiRoute from './MissingAuthorizationApiRoute'; 2 | 3 | type Props = { 4 | params: { 5 | exampleType: string; 6 | }; 7 | }; 8 | 9 | export default function MissingAuthorizationApiRoutePage(props: Props) { 10 | return ( 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/example-5-secrets-exposure/vulnerable/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUsersWithPasswordHash } from '../../../database/users'; 2 | import SecretsExposure from './SecretsExposure'; 3 | 4 | export const dynamic = 'force-dynamic'; 5 | 6 | export default async function SecretsExposurePage() { 7 | const users = await getUsersWithPasswordHash(); 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "security-vulnerability-examples-next-js-postgres" 2 | primary_region = "otp" 3 | swap_size_mb = 512 4 | 5 | # Apps without volume: Comment out the [mounts] configuration below 6 | # [mounts] 7 | # source = "postgres" 8 | # destination = "/postgres-volume" 9 | 10 | [env] 11 | PORT = "8080" 12 | 13 | [http_service] 14 | internal_port = 8080 15 | force_https = true 16 | auto_stop_machines = true 17 | auto_start_machines = true 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable Stylelint in javascript and typescriptreact (for CSS-in-JS) and scss 3 | "css.lint.unknownAtRules": "ignore", 4 | // Ignore unknown CSS at rules for Tailwind CSS 5 | "stylelint.validate": [ 6 | "css", 7 | "scss", 8 | "postcss", 9 | "javascript", 10 | "typescriptreact" 11 | ], 12 | "typescript.enablePromptUseWorkspaceTsdk": true, 13 | "typescript.tsdk": "./node_modules/typescript/lib" 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "eslint-config-upleveled/tsconfig.base.json", 4 | "compilerOptions": { 5 | "plugins": [ 6 | { 7 | "name": "next" 8 | } 9 | ], 10 | "jsx": "preserve" 11 | }, 12 | "include": [ 13 | "**/*.ts", 14 | "**/*.tsx", 15 | "**/*.js", 16 | "**/*.jsx", 17 | "**/*.cjs", 18 | "**/*.mjs", 19 | ".next/types/**/*.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /app/example-1-missing-authentication-route-handler/[exampleType]/page.tsx: -------------------------------------------------------------------------------- 1 | import MissingAuthenticationApiRoute from './MissingAuthenticationApiRoute'; 2 | 3 | export const dynamic = 'force-dynamic'; 4 | 5 | type Props = { 6 | params: { 7 | exampleType: string; 8 | }; 9 | }; 10 | 11 | export default function MissingAuthenticationApiRoutePage(props: Props) { 12 | return ( 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | 25 | # local env files 26 | .env*.local 27 | 28 | # vercel 29 | .vercel 30 | 31 | .eslintcache 32 | *.tsbuildinfo 33 | 34 | .env 35 | -------------------------------------------------------------------------------- /app/example-5-secrets-exposure/solution-1/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUsers } from '../../../database/users'; 2 | import Common, { Colors } from '../common'; 3 | 4 | export const dynamic = 'force-dynamic'; 5 | 6 | export default async function SecretsExposurePage() { 7 | const colorsResponse = await fetch( 8 | `https://reqres.in/api/colors?apiKey=${process.env.API_KEY!}`, 9 | ); 10 | const colors: Colors = await colorsResponse.json(); 11 | 12 | const users = await getUsers(); 13 | 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /app/example-4-missing-authorization-server-component/vulnerable-1/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUnpublishedBlogPosts } from '../../../database/blogPosts'; 2 | import Common from '../common'; 3 | import MissingAuthorizationServerComponent from './MissingAuthorizationServerComponent'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function MissingAuthorizationServerComponentPage() { 8 | const blogPosts = await getUnpublishedBlogPosts(); 9 | 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /migrations/002-create-table-sessions.ts: -------------------------------------------------------------------------------- 1 | import { Sql } from 'postgres'; 2 | 3 | export async function up(sql: Sql>) { 4 | await sql` 5 | CREATE TABLE 6 | sessions ( 7 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 8 | token VARCHAR(90) UNIQUE NOT NULL, 9 | expiry_timestamp TIMESTAMP NOT NULL DEFAULT now () + INTERVAL '24 hours', 10 | user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE 11 | ); 12 | `; 13 | } 14 | 15 | export async function down(sql: Sql>) { 16 | await sql`DROP TABLE sessions`; 17 | } 18 | -------------------------------------------------------------------------------- /app/example-3-missing-authorization-route-handler/vulnerable-2/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getUserByValidSessionToken } from '../../../database/users'; 3 | import MissingAuthorizationApiRoute from './MissingAuthorizationApiRoute'; 4 | 5 | export default async function MissingAuthorizationApiRoutePage() { 6 | const cookieStore = cookies(); 7 | const sessionToken = cookieStore.get('sessionToken'); 8 | const user = !sessionToken?.value 9 | ? undefined 10 | : await getUserByValidSessionToken(sessionToken.value); 11 | 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /app/api/example-1-missing-authentication-route-handler/vulnerable/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { 3 | BlogPost, 4 | getPublishedBlogPosts, 5 | } from '../../../../database/blogPosts'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | 9 | export type MissingAuthenticationApiRouteResponseBodyGet = { 10 | blogPosts: BlogPost[]; 11 | }; 12 | 13 | export async function GET(): Promise< 14 | NextResponse 15 | > { 16 | const blogPosts = await getPublishedBlogPosts(); 17 | 18 | return NextResponse.json({ 19 | blogPosts: blogPosts, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Secrets 2 | *.env 3 | .env.test 4 | .env*.local 5 | .env.development 6 | 7 | # Scripts 8 | *.sql 9 | 10 | # Documentation 11 | README.md 12 | 13 | # Next.js build artifacts 14 | build 15 | .vercel 16 | .next 17 | 18 | # Tests 19 | playwright 20 | playwright.config.ts 21 | coverage 22 | */__tests__ 23 | jest.config.js 24 | 25 | # Dependencies 26 | node_modules 27 | 28 | # Git and GitHub 29 | .git 30 | .github 31 | 32 | # Linting, formatting config 33 | .eslintcache 34 | .eslintignore 35 | .eslintrc 36 | prettier.config.cjs 37 | 38 | # Docker 39 | Dockerfile 40 | .dockerignore 41 | 42 | # TypeScript cache 43 | .tsbuildinfo 44 | -------------------------------------------------------------------------------- /app/LinkIfNotCurrent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link, { LinkProps } from 'next/link'; 4 | import { usePathname } from 'next/navigation'; 5 | 6 | export default function LinkIfNotCurrent({ 7 | matchParentSegment, 8 | ...props 9 | }: LinkProps & { 10 | matchParentSegment?: boolean; 11 | }) { 12 | const pathname = usePathname(); 13 | 14 | if ( 15 | pathname === props.href || 16 | (matchParentSegment && 17 | pathname.startsWith((props.href as string).match(/^(\/.+\/)[^/]+$/)![1]!)) 18 | ) { 19 | return {props.children}; 20 | } 21 | 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /app/example-6-cross-site-scripting/solution-1/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { getBlogPostById } from '../../../database/blogPosts'; 3 | import Common from '../common'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function CrossSiteScriptingPage() { 8 | const blogPost = await getBlogPostById(6); 9 | 10 | if (!blogPost) { 11 | notFound(); 12 | } 13 | 14 | return ( 15 | <> 16 | 17 | 18 |

{blogPost.title}

19 |
Published: {String(blogPost.isPublished)}
20 | 21 |
{blogPost.textContent}
22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/example-4-missing-authorization-server-component/vulnerable-1/MissingAuthorizationServerComponent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { BlogPost } from '../../../database/blogPosts'; 4 | 5 | type Props = { 6 | blogPosts: BlogPost[]; 7 | }; 8 | 9 | export default function MissingAuthorizationServerComponent(props: Props) { 10 | return ( 11 |
12 | {props.blogPosts.map((blogPost) => { 13 | return ( 14 |
15 |

{blogPost.title}

16 |
Published: {String(blogPost.isPublished)}
17 |
{blogPost.textContent}
18 |
19 | ); 20 | })} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/example-6-cross-site-scripting/vulnerable/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { getBlogPostById } from '../../../database/blogPosts'; 3 | import Common from '../common'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function CrossSiteScriptingPage() { 8 | const blogPost = await getBlogPostById(6); 9 | 10 | if (!blogPost) { 11 | notFound(); 12 | } 13 | 14 | return ( 15 | <> 16 | 17 | 18 |

{blogPost.title}

19 |
Published: {String(blogPost.isPublished)}
20 | 21 |
26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/(auth)/logout/route.ts: -------------------------------------------------------------------------------- 1 | import cookie from 'cookie'; 2 | import { cookies } from 'next/headers'; 3 | import { NextResponse } from 'next/server'; 4 | import { deleteSessionByToken } from '../../../database/sessions'; 5 | 6 | export async function GET(): Promise> { 7 | const cookieStore = cookies(); 8 | const token = cookieStore.get('sessionToken'); 9 | 10 | if (token) { 11 | await deleteSessionByToken(token.value); 12 | } 13 | 14 | return NextResponse.json(null, { 15 | status: 307, 16 | headers: { 17 | 'Set-Cookie': cookie.serialize('sessionToken', '', { 18 | maxAge: -1, 19 | expires: new Date(Date.now() - 10000), 20 | }), 21 | location: '/', 22 | }, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /util/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const returnToSchema = z.string().refine((value) => { 4 | return ( 5 | !value.startsWith('/logout') && 6 | // Regular expression for valid returnTo path: 7 | // - starts with a slash 8 | // - until the end of the string, 1 or more: 9 | // - numbers 10 | // - hash symbols 11 | // - forward slashes 12 | // - equals signs 13 | // - question marks 14 | // - lowercase letters 15 | // - dashes 16 | /^\/[\d#/=?a-z-]+$/.test(value) 17 | ); 18 | }); 19 | 20 | export function getSafeReturnToPath(path: string | string[] | undefined) { 21 | const result = returnToSchema.safeParse(path); 22 | if (!result.success) return undefined; 23 | return result.data; 24 | } 25 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { redirect } from 'next/navigation'; 3 | import { getValidSessionByToken } from '../../../database/sessions'; 4 | import LoginForm from './LoginForm'; 5 | 6 | type Props = { searchParams: { returnTo?: string | string[] } }; 7 | 8 | export default async function LoginPage(props: Props) { 9 | // check if i have a valid session 10 | const sessionTokenCookie = cookies().get('sessionToken'); 11 | console.log(sessionTokenCookie); 12 | 13 | const session = 14 | sessionTokenCookie && 15 | (await getValidSessionByToken(sessionTokenCookie.value)); 16 | 17 | // if yes redirect to home 18 | if (session) { 19 | redirect('/'); 20 | } 21 | 22 | // if no render login component 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /app/example-2-missing-authentication-server-component/solution-1/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getPublishedBlogPostsBySessionToken } from '../../../database/blogPosts'; 3 | import Common from '../common'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function MissingAuthenticationServerComponentPage() { 8 | const cookieStore = cookies(); 9 | const sessionToken = cookieStore.get('sessionToken')?.value; 10 | 11 | if (!sessionToken) { 12 | return ; 13 | } 14 | 15 | const blogPosts = await getPublishedBlogPostsBySessionToken(sessionToken); 16 | 17 | if (blogPosts.length < 1) { 18 | return ; 19 | } 20 | 21 | return ; 22 | } 23 | -------------------------------------------------------------------------------- /app/example-5-secrets-exposure/solution-2/SecretsExposure.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from 'react'; 3 | import { User } from '../../../database/users'; 4 | import Common, { Colors } from '../common'; 5 | 6 | type Props = { 7 | users: User[]; 8 | }; 9 | 10 | export default function SecretsExposure(props: Props) { 11 | const [colors, setColors] = useState(null); 12 | 13 | useEffect(() => { 14 | const fetchData = async () => { 15 | const colorsResponse = await fetch( 16 | '/api/example-5-secrets-exposure/solution-2', 17 | ); 18 | 19 | const newColors: Colors = await colorsResponse.json(); 20 | 21 | setColors(newColors); 22 | }; 23 | 24 | fetchData().catch((error) => { 25 | console.error(error); 26 | }); 27 | }, []); 28 | 29 | return ; 30 | } 31 | -------------------------------------------------------------------------------- /app/example-6-cross-site-scripting/solution-2/page.tsx: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | import { JSDOM } from 'jsdom'; 3 | import { notFound } from 'next/navigation'; 4 | import { getBlogPostById } from '../../../database/blogPosts'; 5 | import Common from '../common'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | 9 | export default async function CrossSiteScriptingPage() { 10 | const blogPost = await getBlogPostById(6); 11 | 12 | if (!blogPost) { 13 | notFound(); 14 | } 15 | 16 | return ( 17 | <> 18 | 19 | 20 |

{blogPost.title}

21 |
Published: {String(blogPost.isPublished)}
22 | 23 |
').window).sanitize( 26 | blogPost.textContent, 27 | ), 28 | }} 29 | /> 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/example-2-missing-authentication-server-component/solution-2/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getPublishedBlogPosts } from '../../../database/blogPosts'; 3 | import { getUserByValidSessionToken } from '../../../database/users'; 4 | import Common from '../common'; 5 | 6 | export const dynamic = 'force-dynamic'; 7 | 8 | export default async function MissingAuthenticationServerComponentPage() { 9 | const cookieStore = cookies(); 10 | const sessionToken = cookieStore.get('sessionToken')?.value; 11 | 12 | if (!sessionToken) { 13 | return ; 14 | } 15 | 16 | const user = await getUserByValidSessionToken(sessionToken); 17 | 18 | if (!user) { 19 | return ; 20 | } 21 | 22 | const blogPosts = await getPublishedBlogPosts(); 23 | 24 | return ; 25 | } 26 | -------------------------------------------------------------------------------- /app/example-5-secrets-exposure/vulnerable/SecretsExposure.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from 'react'; 3 | import { User } from '../../../database/users'; 4 | import Common, { Colors } from '../common'; 5 | 6 | type Props = { 7 | apiKey: string; 8 | users: User[]; 9 | }; 10 | 11 | export default function SecretsExposure(props: Props) { 12 | const [colors, setColors] = useState(null); 13 | 14 | useEffect(() => { 15 | const fetchData = async () => { 16 | const colorsResponse = await fetch( 17 | `https://reqres.in/api/colors?apiKey=${props.apiKey}`, 18 | ); 19 | 20 | const newColors: Colors = await colorsResponse.json(); 21 | 22 | setColors(newColors); 23 | }; 24 | 25 | fetchData().catch((error) => { 26 | console.error(error); 27 | }); 28 | }, [props.apiKey]); 29 | 30 | return ; 31 | } 32 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | const prettierConfig = { 3 | plugins: ['prettier-plugin-embed', 'prettier-plugin-sql'], 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | }; 7 | 8 | /** @type {import('prettier-plugin-embed').PrettierPluginEmbedOptions} */ 9 | const prettierPluginEmbedConfig = { 10 | embeddedSqlIdentifiers: ['sql'], 11 | }; 12 | 13 | /** @type {import('prettier-plugin-sql').SqlBaseOptions} */ 14 | const prettierPluginSqlConfig = { 15 | language: 'postgresql', 16 | keywordCase: 'upper', 17 | // - Wrap all parenthesized expressions to new lines (eg. `INSERT` columns) 18 | // - Do not wrap foreign keys (eg. `REFERENCES table_name (id)`) 19 | // - Do not wrap column type expressions (eg. `VARCHAR(255)`) 20 | expressionWidth: 8, 21 | }; 22 | 23 | const config = { 24 | ...prettierConfig, 25 | ...prettierPluginEmbedConfig, 26 | ...prettierPluginSqlConfig, 27 | }; 28 | 29 | export default config; 30 | -------------------------------------------------------------------------------- /app/example-4-missing-authorization-server-component/vulnerable-2/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getUnpublishedBlogPosts } from '../../../database/blogPosts'; 3 | import { getUserByValidSessionToken } from '../../../database/users'; 4 | import Common from '../common'; 5 | import MissingAuthorizationServerComponent from './MissingAuthorizationServerComponent'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | 9 | export default async function MissingAuthorizationServerComponentPage() { 10 | const cookieStore = cookies(); 11 | const sessionToken = cookieStore.get('sessionToken'); 12 | const user = !sessionToken?.value 13 | ? undefined 14 | : await getUserByValidSessionToken(sessionToken.value); 15 | 16 | const blogPosts = await getUnpublishedBlogPosts(); 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /database/connect.ts: -------------------------------------------------------------------------------- 1 | import postgres from 'postgres'; 2 | import { setEnvironmentVariables } from '../ley.config.js'; 3 | 4 | // This loads all environment variables from a .env file 5 | // for all code after this line 6 | setEnvironmentVariables(); 7 | 8 | // Type needed for the connection function below 9 | declare module globalThis { 10 | let postgresSqlClient: ReturnType | undefined; 11 | } 12 | 13 | // Connect only once to the database 14 | // https://github.com/vercel/next.js/issues/7811#issuecomment-715259370 15 | function connectOneTimeToDatabase() { 16 | if (!globalThis.postgresSqlClient) { 17 | globalThis.postgresSqlClient = postgres({ 18 | ssl: Boolean(process.env.POSTGRES_URL), 19 | transform: { 20 | ...postgres.camel, 21 | undefined: null, 22 | }, 23 | }); 24 | } 25 | return globalThis.postgresSqlClient; 26 | } 27 | 28 | // Connect to PostgreSQL 29 | export const sql = connectOneTimeToDatabase(); 30 | -------------------------------------------------------------------------------- /app/example-4-missing-authorization-server-component/vulnerable-2/MissingAuthorizationServerComponent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { BlogPost } from '../../../database/blogPosts'; 4 | import { User } from '../../../database/users'; 5 | 6 | type Props = { 7 | blogPosts: BlogPost[]; 8 | user: User | undefined; 9 | }; 10 | 11 | export default function MissingAuthorizationServerComponent(props: Props) { 12 | return ( 13 |
14 | {props.blogPosts 15 | // Filter to blog posts owned by the user 16 | // Vulnerability fixed? 17 | .filter((blogPost) => { 18 | return blogPost.userId === props.user?.id; 19 | }) 20 | .map((blogPost) => { 21 | return ( 22 |
23 |

{blogPost.title}

24 |
Published: {String(blogPost.isPublished)}
25 |
{blogPost.textContent}
26 |
27 | ); 28 | })} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /util/cookies.ts: -------------------------------------------------------------------------------- 1 | import { serialize } from 'cookie'; 2 | 3 | export function createSessionTokenCookie(token: string) { 4 | // Detect whether we're in a production environment 5 | // eg. Heroku 6 | const isProduction = process.env.NODE_ENV === 'production'; 7 | 8 | // Save the token in a cookie on the user's machine 9 | // (cookies get sent automatically to the server every time 10 | // a user makes a request) 11 | const maxAge = 60 * 60 * 24; // 24 hours 12 | return serialize('sessionToken', token, { 13 | maxAge: maxAge, 14 | 15 | expires: new Date(Date.now() + maxAge * 1000), 16 | 17 | // Important for security 18 | // Deny cookie access from frontend JavaScript 19 | httpOnly: true, 20 | 21 | // Important for security 22 | // Set secure cookies on production (eg. Heroku) 23 | secure: isProduction, 24 | 25 | path: '/', 26 | 27 | // Be explicit about new default behavior 28 | // in browsers 29 | // https://web.dev/samesite-cookies-explained/ 30 | sameSite: 'lax', 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /app/example-4-missing-authorization-server-component/solution-1/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getUnpublishedBlogPostsBySessionToken } from '../../../database/blogPosts'; 3 | import Common from '../common'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | export default async function MissingAuthorizationServerComponentPage() { 8 | const cookieStore = cookies(); 9 | const sessionToken = cookieStore.get('sessionToken'); 10 | 11 | if (!sessionToken) { 12 | return ; 13 | } 14 | 15 | const blogPosts = await getUnpublishedBlogPostsBySessionToken( 16 | sessionToken.value, 17 | ); 18 | 19 | return ( 20 | <> 21 | 22 | 23 | {blogPosts.map((blogPost) => { 24 | return ( 25 |
26 |

{blogPost.title}

27 |
Published: {String(blogPost.isPublished)}
28 |
{blogPost.textContent}
29 |
30 | ); 31 | })} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /migrations/001-create-table-users.ts: -------------------------------------------------------------------------------- 1 | import { Sql } from 'postgres'; 2 | 3 | const users = [ 4 | { 5 | // id: 1, 6 | username: 'alice', 7 | // password: abc 8 | passwordHash: 9 | '$2b$12$rip3gbockwavRttTaMZa.u5JKY1542MOLBI7YGkRXaj83rtocfl3a', 10 | }, 11 | { 12 | // id: 2, 13 | username: 'bob', 14 | // password: def 15 | passwordHash: 16 | '$2b$12$0N14zwm7.gFNB9UriJpo9eHqCBSezv1zdvbLL7ql79KYJM50fvo6q', 17 | }, 18 | ]; 19 | 20 | export async function up(sql: Sql>) { 21 | await sql` 22 | CREATE TABLE 23 | users ( 24 | id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, 25 | username VARCHAR(30) NOT NULL UNIQUE, 26 | password_hash VARCHAR(60) NOT NULL 27 | ); 28 | `; 29 | 30 | for (const user of users) { 31 | await sql` 32 | INSERT INTO 33 | users ( 34 | username, 35 | password_hash 36 | ) 37 | VALUES 38 | ( 39 | ${user.username}, 40 | ${user.passwordHash} 41 | ) 42 | `; 43 | } 44 | } 45 | 46 | export async function down(sql: Sql>) { 47 | await sql`DROP TABLE users`; 48 | } 49 | -------------------------------------------------------------------------------- /app/api/example-1-missing-authentication-route-handler/solution-1/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | BlogPost, 4 | getPublishedBlogPostsBySessionToken, 5 | } from '../../../../database/blogPosts'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | 9 | export type MissingAuthenticationApiRouteResponseBodyGet = 10 | | { error: string } 11 | | { blogPosts: BlogPost[] }; 12 | 13 | export async function GET( 14 | request: NextRequest, 15 | ): Promise> { 16 | const sessionToken = request.cookies.get('sessionToken')?.value; 17 | 18 | if (!sessionToken) { 19 | return NextResponse.json( 20 | { 21 | error: 'Session token not provided', 22 | }, 23 | { 24 | status: 401, 25 | }, 26 | ); 27 | } 28 | 29 | const blogPosts = await getPublishedBlogPostsBySessionToken(sessionToken); 30 | 31 | if (blogPosts.length < 1) { 32 | return NextResponse.json( 33 | { 34 | error: 'Session token not valid (or no blog posts found)', 35 | }, 36 | { 37 | status: 403, 38 | }, 39 | ); 40 | } 41 | 42 | return NextResponse.json({ 43 | blogPosts: blogPosts, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /app/api/example-3-missing-authorization-route-handler/solution-1/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | BlogPost, 4 | getUnpublishedBlogPostsBySessionToken, 5 | } from '../../../../database/blogPosts'; 6 | 7 | export const dynamic = 'force-dynamic'; 8 | 9 | export type MissingAuthorizationApiRouteResponseBodyGet = 10 | | { error: string } 11 | | { blogPosts: BlogPost[] }; 12 | 13 | export async function GET( 14 | request: NextRequest, 15 | ): Promise> { 16 | const sessionToken = request.cookies.get('sessionToken')?.value; 17 | 18 | if (!sessionToken) { 19 | return NextResponse.json( 20 | { 21 | error: 'Session token not provided', 22 | }, 23 | { 24 | status: 401, 25 | }, 26 | ); 27 | } 28 | 29 | const blogPosts = await getUnpublishedBlogPostsBySessionToken(sessionToken); 30 | 31 | if (blogPosts.length < 1) { 32 | return NextResponse.json( 33 | { 34 | error: 'Session token not valid (or no blog posts found)', 35 | }, 36 | { 37 | status: 403, 38 | }, 39 | ); 40 | } 41 | 42 | return NextResponse.json({ 43 | blogPosts: blogPosts, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /app/example-6-cross-site-scripting/solution-3/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import ReactMarkdown from 'react-markdown'; 3 | import { getBlogPostById } from '../../../database/blogPosts'; 4 | import Common from '../common'; 5 | 6 | export const dynamic = 'force-dynamic'; 7 | 8 | export default async function CrossSiteScriptingPage() { 9 | const blogPost = await getBlogPostById(7); 10 | 11 | if (!blogPost) { 12 | notFound(); 13 | } 14 | 15 | return ( 16 | <> 17 | 18 | 19 |

{blogPost.title}

20 |
Published: {String(blogPost.isPublished)}
21 | 22 | {/* 23 | Markdown alone is not safe by default. Many Markdown 24 | libraries will also support full usage of HTML tags, 25 | which opens up XSS attack vectors: 26 | https://www.markdownguide.org/basic-syntax/#html 27 | 28 | react-markdown is safe against XSS by default: 29 | https://github.com/remarkjs/react-markdown#security 30 | 31 | If you decide to use a different Markdown library, 32 | make sure that it is secure or you enable any 33 | configuration options to make it secure 34 | */} 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/example-4-missing-authorization-server-component/solution-2/page.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getUnpublishedBlogPostsByUserId } from '../../../database/blogPosts'; 3 | import { getUserByValidSessionToken } from '../../../database/users'; 4 | import Common from '../common'; 5 | 6 | export const dynamic = 'force-dynamic'; 7 | 8 | export default async function MissingAuthorizationServerComponentPage() { 9 | const cookieStore = cookies(); 10 | const sessionToken = cookieStore.get('sessionToken'); 11 | 12 | if (!sessionToken) { 13 | return ; 14 | } 15 | 16 | const user = await getUserByValidSessionToken(sessionToken.value); 17 | 18 | if (!user) { 19 | return ; 20 | } 21 | 22 | const blogPosts = await getUnpublishedBlogPostsByUserId(user.id); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | {blogPosts.map((blogPost) => { 29 | return ( 30 |
31 |

{blogPost.title}

32 |
Published: {String(blogPost.isPublished)}
33 |
{blogPost.textContent}
34 |
35 | ); 36 | })} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/api/example-1-missing-authentication-route-handler/solution-2/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | BlogPost, 4 | getPublishedBlogPosts, 5 | } from '../../../../database/blogPosts'; 6 | import { getUserByValidSessionToken } from '../../../../database/users'; 7 | 8 | export const dynamic = 'force-dynamic'; 9 | 10 | export type MissingAuthenticationApiRouteResponseBodyGet = 11 | | { error: string } 12 | | { blogPosts: BlogPost[] }; 13 | 14 | export async function GET( 15 | request: NextRequest, 16 | ): Promise> { 17 | const sessionToken = request.cookies.get('sessionToken')?.value; 18 | 19 | if (!sessionToken) { 20 | return NextResponse.json( 21 | { 22 | error: 'Session token not provided', 23 | }, 24 | { 25 | status: 401, 26 | }, 27 | ); 28 | } 29 | 30 | const user = await getUserByValidSessionToken(sessionToken); 31 | 32 | if (!user) { 33 | return NextResponse.json( 34 | { 35 | error: 'Session token not valid', 36 | }, 37 | { 38 | status: 401, 39 | }, 40 | ); 41 | } 42 | 43 | const blogPosts = await getPublishedBlogPosts(); 44 | 45 | return NextResponse.json({ 46 | blogPosts: blogPosts, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /app/api/example-3-missing-authorization-route-handler/vulnerable-1/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | BlogPost, 4 | getUnpublishedBlogPosts, 5 | } from '../../../../database/blogPosts'; 6 | import { getUserByValidSessionToken } from '../../../../database/users'; 7 | 8 | export const dynamic = 'force-dynamic'; 9 | 10 | export type MissingAuthorizationApiRouteResponseBodyGet = 11 | | { error: string } 12 | | { blogPosts: BlogPost[] }; 13 | 14 | export async function GET( 15 | request: NextRequest, 16 | ): Promise> { 17 | const sessionToken = request.cookies.get('sessionToken')?.value; 18 | 19 | if (!sessionToken) { 20 | return NextResponse.json( 21 | { 22 | error: 'Session token not provided', 23 | }, 24 | { 25 | status: 401, 26 | }, 27 | ); 28 | } 29 | 30 | const user = await getUserByValidSessionToken(sessionToken); 31 | 32 | if (!user) { 33 | return NextResponse.json( 34 | { 35 | error: 'Session token not valid', 36 | }, 37 | { 38 | status: 401, 39 | }, 40 | ); 41 | } 42 | 43 | const blogPosts = await getUnpublishedBlogPosts(); 44 | 45 | return NextResponse.json({ 46 | blogPosts: blogPosts, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /app/api/example-5-secrets-exposure/solution-2/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { getUserByValidSessionToken } from '../../../../database/users'; 3 | import { Colors } from '../../../example-5-secrets-exposure/common'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | 7 | type SecretsExposureResponseBodyGet = 8 | | { 9 | error: string; 10 | } 11 | | Colors; 12 | 13 | export async function GET( 14 | request: NextRequest, 15 | ): Promise> { 16 | const sessionToken = request.cookies.get('sessionToken')?.value; 17 | 18 | if (!sessionToken) { 19 | return NextResponse.json( 20 | { 21 | error: 'Session token not provided', 22 | }, 23 | { 24 | status: 401, 25 | }, 26 | ); 27 | } 28 | 29 | const user = await getUserByValidSessionToken(sessionToken); 30 | 31 | if (!user) { 32 | return NextResponse.json( 33 | { 34 | error: 'Session token not valid', 35 | }, 36 | { 37 | status: 401, 38 | }, 39 | ); 40 | } 41 | 42 | const colorsResponse = await fetch( 43 | `https://reqres.in/api/colors?apiKey=${process.env.API_KEY!}`, 44 | ); 45 | const colors: Colors = await colorsResponse.json(); 46 | 47 | return NextResponse.json(colors); 48 | } 49 | -------------------------------------------------------------------------------- /app/example-6-cross-site-scripting/common.tsx: -------------------------------------------------------------------------------- 1 | import LinkIfNotCurrent from '../LinkIfNotCurrent'; 2 | 3 | type Props = { 4 | error?: string; 5 | }; 6 | 7 | export default function Common(props: Props) { 8 | return ( 9 | <> 10 |

Cross-Site Scripting (XSS)

11 | 12 |
    13 |
  • 14 | 15 | Vulnerable 16 | 17 |
  • 18 |
  • 19 | 20 | Solution 1 21 | 22 |
  • 23 |
  • 24 | 25 | Solution 2 26 | 27 |
  • 28 |
  • 29 | 30 | Solution 3 31 | 32 |
  • 33 |
34 | 35 |
36 | 37 |
38 | The following blog post should not cause any arbitrary JavaScript to 39 | run. 40 |
41 | 42 |

Blog Post

43 | 44 | {'error' in props &&
{props.error}
} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/api/example-3-missing-authorization-route-handler/solution-2/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { 3 | BlogPost, 4 | getUnpublishedBlogPostsByUserId, 5 | } from '../../../../database/blogPosts'; 6 | import { getUserByValidSessionToken } from '../../../../database/users'; 7 | 8 | export const dynamic = 'force-dynamic'; 9 | 10 | export type MissingAuthorizationApiRouteResponseBodyGet = 11 | | { error: string } 12 | | { blogPosts: BlogPost[] }; 13 | 14 | export async function GET( 15 | request: NextRequest, 16 | ): Promise> { 17 | const sessionToken = request.cookies.get('sessionToken')?.value; 18 | 19 | if (!sessionToken) { 20 | return NextResponse.json( 21 | { 22 | error: 'Session token not provided', 23 | }, 24 | { 25 | status: 401, 26 | }, 27 | ); 28 | } 29 | 30 | const user = await getUserByValidSessionToken(sessionToken); 31 | 32 | if (!user) { 33 | return NextResponse.json( 34 | { 35 | error: 'Session token not valid', 36 | }, 37 | { 38 | status: 401, 39 | }, 40 | ); 41 | } 42 | 43 | const blogPosts = await getUnpublishedBlogPostsByUserId(user.id); 44 | 45 | return NextResponse.json({ 46 | blogPosts: blogPosts, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export default function HomePage() { 4 | return ( 5 |
6 |
    7 |
  1. 8 | 9 | Example 1: Missing Authentication - Route Handler 10 | 11 |
  2. 12 |
  3. 13 | 14 | Example 2: Missing Authentication - Server Component 15 | 16 |
  4. 17 |
  5. 18 | 19 | Example 3: Missing Authorization - Route Handler 20 | 21 |
  6. 22 |
  7. 23 | 24 | Example 4: Missing Authorization - Server Component 25 | 26 |
  8. 27 |
  9. 28 | 29 | Example 5: Data Exposure 30 | 31 |
  10. 32 |
  11. 33 | 34 | Example 6: Cross-Site Scripting (XSS) 35 | 36 |
  12. 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "security-vulnerability-examples-next-js-postgres", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "next build", 8 | "dev": "next dev", 9 | "lint": "next lint", 10 | "migrate": "ley --require tsm", 11 | "start": "next start" 12 | }, 13 | "dependencies": { 14 | "@types/cookie": "0.6.0", 15 | "bcrypt": "5.1.1", 16 | "canvas": "2.11.2", 17 | "cookie": "0.6.0", 18 | "dompurify": "3.0.8", 19 | "dotenv": "^16.3.1", 20 | "jsdom": "24.0.0", 21 | "ley": "0.8.1", 22 | "next": "14.1.0", 23 | "postgres": "3.4.3", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-markdown": "9.0.1", 27 | "sass": "1.70.0", 28 | "sharp": "0.33.2", 29 | "tsm": "2.3.0", 30 | "zod": "3.22.4" 31 | }, 32 | "devDependencies": { 33 | "@ts-safeql/eslint-plugin": "2.0.3", 34 | "@types/bcrypt": "5.0.2", 35 | "@types/dompurify": "3.0.5", 36 | "@types/jsdom": "21.1.6", 37 | "@types/node": "20.11.17", 38 | "@types/react": "18.2.55", 39 | "@types/react-dom": "18.2.19", 40 | "eslint": "8.56.0", 41 | "eslint-config-upleveled": "7.7.1", 42 | "libpg-query": "15.0.2", 43 | "prettier-plugin-embed": "0.4.13", 44 | "prettier-plugin-sql": "0.18.0", 45 | "stylelint": "16.2.1", 46 | "stylelint-config-upleveled": "1.0.7", 47 | "typescript": "5.3.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ley.config.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import dotenv from 'dotenv'; 3 | 4 | export function setEnvironmentVariables() { 5 | if (process.env.NODE_ENV === 'production' || process.env.CI) { 6 | // Set standard environment variables for Postgres.js from Vercel environment variables 7 | if (process.env.POSTGRES_URL) { 8 | process.env.PGHOST = process.env.POSTGRES_HOST; 9 | process.env.PGDATABASE = process.env.POSTGRES_DATABASE; 10 | process.env.PGUSERNAME = process.env.POSTGRES_USER; 11 | process.env.PGPASSWORD = process.env.POSTGRES_PASSWORD; 12 | } 13 | return; 14 | } 15 | 16 | // Replacement for unmaintained dotenv-safe package 17 | // https://github.com/rolodato/dotenv-safe/issues/128#issuecomment-1383176751 18 | // 19 | // TODO: Remove this and switch to dotenv/safe if this proposal gets implemented: 20 | // https://github.com/motdotla/dotenv/issues/709 21 | dotenv.config(); 22 | 23 | const unconfiguredEnvVars = Object.keys( 24 | dotenv.parse(fs.readFileSync('./.env.example')), 25 | ).filter((exampleKey) => !process.env[exampleKey]); 26 | 27 | if (unconfiguredEnvVars.length > 0) { 28 | throw new Error( 29 | `.env.example environment ${ 30 | unconfiguredEnvVars.length > 1 ? 'variables' : 'variable' 31 | } ${unconfiguredEnvVars.join(', ')} not configured in .env file`, 32 | ); 33 | } 34 | } 35 | 36 | setEnvironmentVariables(); 37 | 38 | const options = { 39 | ssl: Boolean(process.env.POSTGRES_URL), 40 | }; 41 | 42 | export default options; 43 | -------------------------------------------------------------------------------- /app/example-4-missing-authorization-server-component/common.tsx: -------------------------------------------------------------------------------- 1 | import LinkIfNotCurrent from '../LinkIfNotCurrent'; 2 | 3 | type Props = { 4 | error?: string; 5 | }; 6 | 7 | export default function Common(props: Props) { 8 | return ( 9 | <> 10 |

Missing Authorization - Server Component

11 | 12 |
    13 |
  • 14 | 15 | Vulnerable 1 16 | 17 |
  • 18 |
  • 19 | 20 | Vulnerable 2 21 | 22 |
  • 23 |
  • 24 | 25 | Solution 1 26 | 27 |
  • 28 |
  • 29 | 30 | Solution 2 31 | 32 |
  • 33 |
34 | 35 |
36 | 37 |
38 | Below, a list of unpublished blog posts will appear for logged-in users 39 | - similar to a "Drafts" list in a CMS. 40 |
41 |
42 | Each unpublished blog post should only be visible for the owner of the 43 | post. 44 |
45 | 46 |

Unpublished Blog Posts

47 | 48 | {!!props.error &&
{props.error}
} 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /database/sessions.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | import { sql } from './connect'; 3 | 4 | type Session = { 5 | id: number; 6 | token: string; 7 | userId: number; 8 | }; 9 | 10 | export const deleteExpiredSessions = cache(async () => { 11 | const sessions = await sql` 12 | DELETE FROM sessions 13 | WHERE 14 | expiry_timestamp < now () RETURNING id, 15 | token, 16 | user_id 17 | `; 18 | return sessions; 19 | }); 20 | 21 | export const getValidSessionByToken = cache( 22 | async (token: string | undefined) => { 23 | if (!token) return undefined; 24 | const [session] = await sql` 25 | SELECT 26 | id, 27 | token, 28 | user_id 29 | FROM 30 | sessions 31 | WHERE 32 | token = ${token} 33 | AND expiry_timestamp > now () 34 | `; 35 | 36 | await deleteExpiredSessions(); 37 | 38 | return session; 39 | }, 40 | ); 41 | 42 | export const createSession = cache(async (token: string, userId: number) => { 43 | const [session] = await sql` 44 | INSERT INTO 45 | sessions ( 46 | token, 47 | user_id 48 | ) 49 | VALUES 50 | ( 51 | ${token}, 52 | ${userId} 53 | ) RETURNING id, 54 | token, 55 | user_id 56 | `; 57 | 58 | await deleteExpiredSessions(); 59 | 60 | return session; 61 | }); 62 | 63 | export const deleteSessionByToken = cache(async (token: string) => { 64 | const [session] = await sql` 65 | DELETE FROM sessions 66 | WHERE 67 | token = ${token} RETURNING id, 68 | token, 69 | user_id 70 | `; 71 | return session; 72 | }); 73 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Initialize builder layer 2 | FROM node:lts-alpine AS builder 3 | ENV NODE_ENV production 4 | # Install necessary tools 5 | RUN apk add --no-cache libc6-compat yq build-base g++ cairo-dev jpeg-dev pango-dev giflib-dev --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community 6 | # Install pnpm 7 | RUN corepack enable && corepack prepare pnpm@latest --activate 8 | WORKDIR /app 9 | # Copy the content of the project to the machine 10 | COPY . . 11 | RUN yq --inplace --output-format=json '(.dependencies = .dependencies * (.devDependencies | to_entries | map(select(.key | test("^(typescript|@types/*|eslint-config-upleveled)$"))) | from_entries)) | (.devDependencies = {})' package.json 12 | RUN pnpm install 13 | RUN pnpm build 14 | 15 | # Initialize runner layer 16 | FROM node:lts-alpine AS runner 17 | ENV NODE_ENV production 18 | # Install necessary tools 19 | RUN apk add bash postgresql cairo pango jpeg musl giflib pixman pangomm libjpeg-turbo freetype 20 | # Install pnpm 21 | RUN corepack enable && corepack prepare pnpm@latest --activate 22 | WORKDIR /app 23 | 24 | # Copy built app 25 | COPY --from=builder /app/.next ./.next 26 | 27 | # Copy only necessary files to run the app (minimize production app size, improve performance) 28 | COPY --from=builder /app/node_modules ./node_modules 29 | COPY --from=builder /app/migrations ./migrations 30 | COPY --from=builder /app/public ./public 31 | COPY --from=builder /app/package.json ./ 32 | COPY --from=builder /app/.env.production ./ 33 | COPY --from=builder /app/next.config.js ./ 34 | 35 | # Copy start script and make it executable 36 | COPY --from=builder /app/scripts ./scripts 37 | RUN chmod +x /app/scripts/fly-io-start.sh 38 | 39 | CMD ["./scripts/fly-io-start.sh"] 40 | -------------------------------------------------------------------------------- /app/example-2-missing-authentication-server-component/common.tsx: -------------------------------------------------------------------------------- 1 | import { BlogPost } from '../../database/blogPosts'; 2 | import LinkIfNotCurrent from '../LinkIfNotCurrent'; 3 | 4 | type Props = 5 | | { 6 | error: string; 7 | } 8 | | { 9 | blogPosts: BlogPost[]; 10 | }; 11 | 12 | export default function Common(props: Props) { 13 | return ( 14 | <> 15 |

Missing Authentication - Server Component

16 | 17 |
    18 |
  • 19 | 20 | Vulnerable 21 | 22 |
  • 23 |
  • 24 | 25 | Solution 1 26 | 27 |
  • 28 |
  • 29 | 30 | Solution 2 31 | 32 |
  • 33 |
34 | 35 |
36 | 37 |
38 | The following blog posts should only be visible for logged-in users. 39 |
40 |
41 | If a user is not logged in, an error message should appear. 42 |
43 | 44 |

Blog Posts

45 | 46 | {'error' in props &&
{props.error}
} 47 | 48 | {'blogPosts' in props && 49 | props.blogPosts.map((blogPost) => { 50 | return ( 51 |
52 |

{blogPost.title}

53 |
Published: {String(blogPost.isPublished)}
54 |
{blogPost.textContent}
55 |
56 | ); 57 | })} 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/example-5-secrets-exposure/common.tsx: -------------------------------------------------------------------------------- 1 | import { User } from '../../database/users'; 2 | import LinkIfNotCurrent from '../LinkIfNotCurrent'; 3 | 4 | export type Colors = { 5 | page: number; 6 | per_page: number; 7 | total: number; 8 | total_pages: number; 9 | data: { 10 | id: number; 11 | name: string; 12 | year: number; 13 | color: string; 14 | pantone_value: string; 15 | }[]; 16 | support: { 17 | url: string; 18 | text: string; 19 | }; 20 | } | null; 21 | 22 | type Props = { 23 | apiKey?: string; 24 | colors: Colors; 25 | users: User[]; 26 | }; 27 | 28 | export default function Common(props: Props) { 29 | return ( 30 | <> 31 |

Secrets Exposure

32 | 33 |
    34 |
  • 35 | 36 | Vulnerable 37 | 38 |
  • 39 |
  • 40 | 41 | Solution 1 42 | 43 |
  • 44 |
  • 45 | 46 | Solution 2 47 | {' '} 48 | - API code:{' '} 49 | pages/api/example-5-secrets-exposure/solution-2.ts 50 |
  • 51 |
52 | 53 |
54 | 55 |
56 | The following API key should not be any value other than "undefined" in 57 | the frontend regardless of which user tries to access the page: 58 |
59 | 60 |
process.env.API_KEY: {JSON.stringify(props.apiKey)}
61 | 62 |
63 | 64 |
65 | 66 | Show API results fetched using the process.env.API_KEY variable 67 | 68 | 69 |
{JSON.stringify(props.colors, null, 2)}
70 |
71 | 72 |
73 | 74 |
75 | The following users should not contain the "passwordHash" property, 76 | regardless of which user tries to access the page: 77 |
78 | 79 |
{JSON.stringify(props.users, null, 2)}
80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /scripts/fly-io-start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit if any command exits with a non-zero exit code 4 | set -o errexit 5 | 6 | # Set volume path for use in PostgreSQL paths if volume directory exists 7 | [ -d "../postgres-volume" ] && VOLUME_PATH=/postgres-volume 8 | 9 | echo "Creating folders for PostgreSQL and adding permissions for postgres user..." 10 | mkdir -p $VOLUME_PATH/run/postgresql/data/ 11 | chown postgres:postgres $VOLUME_PATH/run/postgresql/ $VOLUME_PATH/run/postgresql/data/ 12 | 13 | # If PostgreSQL config file exists, start database. Otherwise, initialize, configure and create user and database. 14 | # 15 | # Config file doesn't exist during: 16 | # 1. First deployment of an app with a volume 17 | # 2. Every deployment of an app without a volume 18 | # 19 | if [[ -f $VOLUME_PATH/run/postgresql/data/postgresql.conf ]]; then 20 | echo "PostgreSQL config file exists, starting database..." 21 | su postgres -c "pg_ctl start -D /postgres-volume/run/postgresql/data/" 22 | else 23 | echo "PostgreSQL config file doesn't exist, initializing database..." 24 | 25 | # Initialize a database in the data directory 26 | su postgres -c "initdb -D $VOLUME_PATH/run/postgresql/data/" 27 | 28 | # Update PostgreSQL config path to use volume location if app has a volume 29 | sed -i "s/'\/run\/postgresql'/'\/postgres-volume\/run\/postgresql'/g" /postgres-volume/run/postgresql/data/postgresql.conf || echo "PostgreSQL volume not mounted, running database as non-persistent (new deploys erase changes not saved in migrations)" 30 | 31 | # Configure PostgreSQL to listen for connections from any address 32 | echo "listen_addresses='*'" >> $VOLUME_PATH/run/postgresql/data/postgresql.conf 33 | 34 | # Start database 35 | su postgres -c "pg_ctl start -D $VOLUME_PATH/run/postgresql/data/" 36 | 37 | # Create database and user with credentials from Fly.io secrets 38 | psql -U postgres postgres << SQL 39 | CREATE DATABASE $PGDATABASE; 40 | CREATE USER $PGUSERNAME WITH ENCRYPTED PASSWORD '$PGPASSWORD'; 41 | GRANT ALL PRIVILEGES ON DATABASE $PGDATABASE TO $PGUSERNAME; 42 | \\connect $PGDATABASE; 43 | CREATE SCHEMA $PGUSERNAME AUTHORIZATION $PGUSERNAME; 44 | SQL 45 | fi 46 | 47 | pnpm migrate up 48 | ./node_modules/.bin/next start 49 | -------------------------------------------------------------------------------- /.github/workflows/lint-check-types-build-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Lint, Check Types, Build, Deploy to Fly.io 2 | on: 3 | pull_request_target: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint-and-check-types-and-build: 10 | name: Lint, Check Types, Build 11 | runs-on: ubuntu-latest 12 | env: 13 | PGHOST: localhost 14 | PGDATABASE: security_vulnerability_examples 15 | PGUSERNAME: security_vulnerability_examples 16 | PGPASSWORD: security_vulnerability_examples 17 | steps: 18 | - name: Start preinstalled PostgreSQL on Ubuntu 19 | run: | 20 | sudo systemctl start postgresql.service 21 | pg_isready 22 | - name: Create database user 23 | run: | 24 | sudo -u postgres psql --command="CREATE USER security_vulnerability_examples PASSWORD 'security_vulnerability_examples'" --command="\du" 25 | - name: Create database and allow user 26 | run: | 27 | sudo -u postgres createdb --owner=security_vulnerability_examples security_vulnerability_examples 28 | - uses: actions/checkout@v4 29 | - uses: pnpm/action-setup@v2 30 | with: 31 | version: 'latest' 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version: 'lts/*' 35 | cache: 'pnpm' 36 | - name: Install dependencies 37 | run: pnpm install 38 | - run: pnpm migrate up 39 | # Also generates next-env.d.ts, required for tsc 40 | - name: Build Next.js app 41 | run: pnpm build 42 | env: 43 | API_KEY: ${{ secrets.API_KEY }} 44 | NODE_ENV: production 45 | - name: Run TypeScript Compiler 46 | run: pnpm tsc 47 | - name: Run ESLint 48 | run: pnpm eslint . --max-warnings 0 49 | env: 50 | API_KEY: ${{ secrets.API_KEY }} 51 | - name: Run Stylelint 52 | run: pnpm stylelint '**/*.{css,scss,less,js,tsx}' 53 | deploy: 54 | name: Deploy to Fly.io 55 | runs-on: ubuntu-latest 56 | needs: lint-and-check-types-and-build 57 | if: github.ref == 'refs/heads/main' 58 | env: 59 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 60 | steps: 61 | - uses: actions/checkout@v4 62 | - uses: superfly/flyctl-actions/setup-flyctl@master 63 | - run: flyctl deploy --remote-only 64 | -------------------------------------------------------------------------------- /app/(auth)/api/login/route.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | import bcrypt from 'bcrypt'; 3 | import { NextRequest, NextResponse } from 'next/server'; 4 | import { z } from 'zod'; 5 | import { createSession } from '../../../../database/sessions'; 6 | import { getUserWithPasswordHashByUsername } from '../../../../database/users'; 7 | import { createSessionTokenCookie } from '../../../../util/cookies'; 8 | 9 | const userSchema = z.object({ 10 | username: z.string(), 11 | password: z.string(), 12 | }); 13 | 14 | export type LoginResponseBodyPost = 15 | | { errors: { message: string }[] } 16 | | { user: { username: string } }; 17 | 18 | export async function POST( 19 | request: NextRequest, 20 | ): Promise> { 21 | const body = await request.json(); 22 | const result = userSchema.safeParse(body); 23 | 24 | if (!result.success) { 25 | return NextResponse.json( 26 | { 27 | errors: result.error.issues, 28 | }, 29 | { status: 400 }, 30 | ); 31 | } 32 | 33 | if (!result.data.username || !result.data.password) { 34 | return NextResponse.json( 35 | { errors: [{ message: 'Username or password is empty' }] }, 36 | { status: 400 }, 37 | ); 38 | } 39 | 40 | const userWithPasswordHash = await getUserWithPasswordHashByUsername( 41 | result.data.username, 42 | ); 43 | 44 | if (!userWithPasswordHash) { 45 | return NextResponse.json( 46 | { errors: [{ message: 'User not found' }] }, 47 | { status: 401 }, 48 | ); 49 | } 50 | 51 | const isPasswordValid = await bcrypt.compare( 52 | result.data.password, 53 | userWithPasswordHash.passwordHash, 54 | ); 55 | 56 | if (!isPasswordValid) { 57 | return NextResponse.json( 58 | { errors: [{ message: 'Password is not valid' }] }, 59 | { status: 401 }, 60 | ); 61 | } 62 | 63 | const token = crypto.randomBytes(64).toString('base64'); 64 | 65 | const session = await createSession(token, userWithPasswordHash.id); 66 | 67 | if (!session) { 68 | return NextResponse.json( 69 | { errors: [{ message: 'Session creation failed' }] }, 70 | { status: 500 }, 71 | ); 72 | } 73 | 74 | const sessionTokenCookie = createSessionTokenCookie(session.token); 75 | 76 | return NextResponse.json( 77 | { 78 | user: { 79 | username: userWithPasswordHash.username, 80 | }, 81 | }, 82 | { 83 | status: 200, 84 | headers: { 'Set-Cookie': sessionTokenCookie }, 85 | }, 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /app/(auth)/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Route } from 'next'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useState } from 'react'; 6 | import { getSafeReturnToPath } from '../../../util/validation'; 7 | import { LoginResponseBodyPost } from '../api/login/route'; 8 | 9 | export default function LoginForm(props: { returnTo?: string | string[] }) { 10 | const [username, setUsername] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | const [errors, setErrors] = useState<{ message: string }[]>([]); 13 | const router = useRouter(); 14 | 15 | return ( 16 | <> 17 |

Login

18 | 19 |
Try the following combinations:
20 | 21 |
{`username: alice / password: abc
22 | 
23 | username: bob / password: def`}
24 | 25 |
{ 27 | event.preventDefault(); 28 | const loginResponse = await fetch('/api/login', { 29 | method: 'POST', 30 | headers: { 31 | 'Content-Type': 'application/json', 32 | }, 33 | body: JSON.stringify({ 34 | username: username, 35 | password: password, 36 | }), 37 | }); 38 | 39 | const loginResponseBody: LoginResponseBodyPost = 40 | await loginResponse.json(); 41 | 42 | if ('errors' in loginResponseBody) { 43 | setErrors(loginResponseBody.errors); 44 | return; 45 | } 46 | 47 | const returnTo = getSafeReturnToPath(props.returnTo); 48 | 49 | if (returnTo) { 50 | router.push(returnTo as Route); 51 | return; 52 | } 53 | 54 | router.replace('/'); 55 | router.refresh(); 56 | }} 57 | > 58 | 65 | 73 | 74 |
75 | 76 |
77 | {errors.map((error) => { 78 | return
{error.message}
; 79 | })} 80 |
81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /database/users.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | import { sql } from './connect'; 3 | 4 | export type User = { 5 | id: number; 6 | username: string; 7 | }; 8 | 9 | export type UserWithPasswordHash = User & { 10 | passwordHash: string; 11 | }; 12 | 13 | export const getUsers = cache(async () => { 14 | const users = await sql` 15 | SELECT 16 | id, 17 | username 18 | FROM 19 | users 20 | `; 21 | return users; 22 | }); 23 | 24 | export const getUsersWithPasswordHash = cache(async () => { 25 | const users = await sql` 26 | SELECT 27 | * 28 | FROM 29 | users 30 | `; 31 | return users; 32 | }); 33 | 34 | export const getUserById = cache(async (id: number) => { 35 | const [user] = await sql` 36 | SELECT 37 | id, 38 | username 39 | FROM 40 | users 41 | WHERE 42 | id = ${id} 43 | `; 44 | return user; 45 | }); 46 | 47 | export const getUserByValidSessionToken = cache( 48 | async (token: string | undefined) => { 49 | if (!token) return undefined; 50 | const [user] = await sql` 51 | SELECT 52 | users.id, 53 | users.username 54 | FROM 55 | sessions 56 | INNER JOIN users ON sessions.user_id = users.id 57 | WHERE 58 | sessions.token = ${token} 59 | AND sessions.expiry_timestamp > now () 60 | `; 61 | return user; 62 | }, 63 | ); 64 | 65 | export const getUserByUsername = cache(async (username: string) => { 66 | const [user] = await sql[]>` 67 | SELECT 68 | id 69 | FROM 70 | users 71 | WHERE 72 | username = ${username} 73 | `; 74 | return user; 75 | }); 76 | 77 | export const getUserWithPasswordHashByUsername = cache( 78 | async (username: string) => { 79 | const [user] = await sql` 80 | SELECT 81 | id, 82 | username, 83 | password_hash 84 | FROM 85 | users 86 | WHERE 87 | username = ${username} 88 | `; 89 | return user; 90 | }, 91 | ); 92 | 93 | export const createUser = cache( 94 | async (username: string, passwordHash: string) => { 95 | const [user] = await sql` 96 | INSERT INTO 97 | users ( 98 | username, 99 | password_hash 100 | ) 101 | VALUES 102 | ( 103 | ${username}, 104 | ${passwordHash} 105 | ) RETURNING id, 106 | username 107 | `; 108 | return user!; 109 | }, 110 | ); 111 | -------------------------------------------------------------------------------- /migrations/003-create-table-blog-posts.ts: -------------------------------------------------------------------------------- 1 | import { Sql } from 'postgres'; 2 | 3 | const blogPosts = [ 4 | { 5 | title: "Alice's first post (published)", 6 | textContent: 7 | "This is Alice's first post. It's published, so this is data that all logged-in users are allowed to view.", 8 | isPublished: true, 9 | userId: 1, 10 | }, 11 | { 12 | title: "Alice's second post (unpublished)", 13 | textContent: 14 | "This is Alice's second post. It's not published, so this is private data that only Alice should be able to view and edit.", 15 | isPublished: false, 16 | userId: 1, 17 | }, 18 | { 19 | title: "Alice's third post (published)", 20 | textContent: 21 | "This is Alice's third post. It's published, so this is data that all logged-in users are allowed to view.", 22 | isPublished: true, 23 | userId: 1, 24 | }, 25 | { 26 | title: "Bob's first post (unpublished)", 27 | textContent: 28 | "This is Bob's first post. It's not published, so this is data only Bob should be able to view and edit.", 29 | isPublished: false, 30 | userId: 2, 31 | }, 32 | { 33 | title: "Bob's second post (published)", 34 | textContent: 35 | "This is Bob's second post. It's published, so this is data that all logged-in users are allowed to view.", 36 | isPublished: true, 37 | userId: 2, 38 | }, 39 | { 40 | title: "Bob's HTML post (published)", 41 | textContent: 42 | 'This is Bob\'s blog post using HTML and an image: ', 43 | isPublished: true, 44 | userId: 1, 45 | }, 46 | { 47 | title: "Bob's Markdown post (published)", 48 | textContent: 49 | 'This is Bob\'s blog post using **Markdown** and an image in HTML: ', 50 | isPublished: true, 51 | userId: 1, 52 | }, 53 | ]; 54 | 55 | export async function up(sql: Sql>) { 56 | await sql` 57 | CREATE TABLE 58 | IF NOT EXISTS blog_posts ( 59 | id INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, 60 | title VARCHAR(100) NOT NULL, 61 | text_content VARCHAR(2000) NOT NULL, 62 | is_published BOOLEAN NOT NULL DEFAULT FALSE, 63 | user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE 64 | ) 65 | `; 66 | 67 | for (const blogPost of blogPosts) { 68 | await sql` 69 | INSERT INTO 70 | blog_posts ( 71 | title, 72 | text_content, 73 | is_published, 74 | user_id 75 | ) 76 | VALUES 77 | ( 78 | ${blogPost.title}, 79 | ${blogPost.textContent}, 80 | ${blogPost.isPublished}, 81 | ${blogPost.userId} 82 | ) 83 | `; 84 | } 85 | } 86 | 87 | export async function down(sql: Sql>) { 88 | await sql`DROP TABLE blog_posts`; 89 | } 90 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './global.scss'; 2 | import { cookies } from 'next/headers'; 3 | import Link from 'next/link'; 4 | import { getUserByValidSessionToken } from '../database/users'; 5 | import LinkIfNotCurrent from './LinkIfNotCurrent'; 6 | 7 | export const metadata = { 8 | title: { 9 | default: 'Next.js + Postgres.js: Broken Security Examples', 10 | template: '%s | Next.js + Postgres.js: Broken Security Examples', 11 | }, 12 | icons: { 13 | shortcut: '/favicon.ico', 14 | }, 15 | }; 16 | 17 | type Props = { 18 | children: React.ReactNode; 19 | }; 20 | 21 | export const dynamic = 'force-dynamic'; 22 | 23 | export default async function RootLayout(props: Props) { 24 | const cookieStore = cookies(); 25 | const sessionToken = cookieStore.get('sessionToken'); 26 | const user = !sessionToken?.value 27 | ? undefined 28 | : await getUserByValidSessionToken(sessionToken.value); 29 | 30 | return ( 31 | 32 | 33 | 34 |
35 | 90 |
91 | 92 |
{props.children}
93 | 94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /database/blogPosts.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | import { sql } from './connect'; 3 | 4 | export type BlogPost = { 5 | id: number; 6 | title: string; 7 | textContent: string; 8 | isPublished: boolean; 9 | userId: number; 10 | }; 11 | 12 | export const getBlogPosts = cache(async () => { 13 | const blogPosts = await sql` 14 | SELECT 15 | * 16 | FROM 17 | blog_posts 18 | `; 19 | return blogPosts; 20 | }); 21 | 22 | export const getBlogPostById = cache(async (id: number) => { 23 | const [blogPost] = await sql` 24 | SELECT 25 | * 26 | FROM 27 | blog_posts 28 | WHERE 29 | id = ${id} 30 | `; 31 | return blogPost; 32 | }); 33 | 34 | export const getPublishedBlogPosts = cache(async () => { 35 | const blogPosts = await sql` 36 | SELECT 37 | * 38 | FROM 39 | blog_posts 40 | WHERE 41 | is_published = TRUE 42 | `; 43 | return blogPosts; 44 | }); 45 | 46 | export const getPublishedBlogPostsBySessionToken = cache( 47 | async (sessionToken: string) => { 48 | const blogPosts = await sql` 49 | SELECT 50 | blog_posts.* 51 | FROM 52 | blog_posts 53 | INNER JOIN sessions ON ( 54 | sessions.token = ${sessionToken} 55 | AND sessions.expiry_timestamp > now () 56 | ) 57 | WHERE 58 | is_published = TRUE 59 | `; 60 | return blogPosts; 61 | }, 62 | ); 63 | 64 | export const getUnpublishedBlogPosts = cache(async () => { 65 | const blogPosts = await sql` 66 | SELECT 67 | * 68 | FROM 69 | blog_posts 70 | WHERE 71 | is_published = FALSE 72 | `; 73 | return blogPosts; 74 | }); 75 | 76 | export const getUnpublishedBlogPostsBySessionToken = cache( 77 | async (sessionToken: string) => { 78 | const blogPosts = await sql` 79 | SELECT 80 | blog_posts.* 81 | FROM 82 | blog_posts 83 | INNER JOIN sessions ON ( 84 | sessions.token = ${sessionToken} 85 | AND sessions.expiry_timestamp > now () 86 | AND sessions.user_id = blog_posts.user_id 87 | ) 88 | WHERE 89 | is_published = FALSE 90 | `; 91 | return blogPosts; 92 | }, 93 | ); 94 | 95 | export const getUnpublishedBlogPostsByUserId = cache(async (userId: number) => { 96 | const blogPosts = await sql` 97 | SELECT 98 | * 99 | FROM 100 | blog_posts 101 | WHERE 102 | is_published = FALSE 103 | AND user_id = ${userId} 104 | `; 105 | return blogPosts; 106 | }); 107 | 108 | export const getBlogPostsBySessionToken = cache( 109 | async (sessionToken: string) => { 110 | const blogPosts = await sql` 111 | SELECT 112 | blog_posts.* 113 | FROM 114 | blog_posts 115 | LEFT JOIN sessions ON ( 116 | sessions.token = ${sessionToken} 117 | AND sessions.expiry_timestamp > now () 118 | ) 119 | WHERE 120 | sessions.user_id = blog_posts.user_id 121 | `; 122 | return blogPosts; 123 | }, 124 | ); 125 | -------------------------------------------------------------------------------- /app/example-1-missing-authentication-route-handler/[exampleType]/MissingAuthenticationApiRoute.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { useEffect, useState } from 'react'; 5 | import { BlogPost } from '../../../database/blogPosts'; 6 | import { MissingAuthenticationApiRouteResponseBodyGet } from '../../api/example-1-missing-authentication-route-handler/solution-1/route'; 7 | import LinkIfNotCurrent from '../../LinkIfNotCurrent'; 8 | 9 | type Props = { 10 | exampleType: string; 11 | }; 12 | 13 | export default function MissingAuthenticationApiRoute(props: Props) { 14 | const [error, setError] = useState(); 15 | const [blogPosts, setBlogPosts] = useState([]); 16 | 17 | if ( 18 | !props.exampleType || 19 | !/^(vulnerable|solution-\d)$/.test(props.exampleType) 20 | ) { 21 | notFound(); 22 | } 23 | 24 | useEffect(() => { 25 | async function fetchInitialData() { 26 | const response = await fetch( 27 | `/api/example-1-missing-authentication-route-handler/${props.exampleType}`, 28 | ); 29 | 30 | const data: MissingAuthenticationApiRouteResponseBodyGet = 31 | await response.json(); 32 | 33 | if ('error' in data) { 34 | setError(data.error); 35 | return; 36 | } 37 | 38 | setError(undefined); 39 | setBlogPosts(data.blogPosts); 40 | } 41 | 42 | fetchInitialData().catch(() => {}); 43 | }, [props.exampleType]); 44 | 45 | return ( 46 | <> 47 |

Missing Authentication - Route Handler

48 | 49 |
    50 |
  • 51 | 52 | Vulnerable 53 | {' '} 54 | - API code:{' '} 55 | 56 | app/api/example-1-missing-authentication-route-handler/vulnerable/route.ts 57 | 58 |
  • 59 |
  • 60 | 61 | Solution 1 62 | {' '} 63 | - API code:{' '} 64 | 65 | app/api/example-1-missing-authentication-route-handler/solution-1/route.ts 66 | 67 |
  • 68 |
  • 69 | 70 | Solution 2 71 | {' '} 72 | - API code:{' '} 73 | 74 | app/api/example-1-missing-authentication-route-handler/solution-2/route.ts 75 | 76 |
  • 77 |
78 | 79 |
80 | 81 |
82 | The following blog posts should only be visible for logged-in users. 83 |
84 |
85 | If a user is not logged in, an error message should appear. 86 |
87 | 88 |

Blog Posts

89 | 90 | {!!error &&
{error}
} 91 | 92 | {blogPosts.map((blogPost) => { 93 | return ( 94 |
95 |

{blogPost.title}

96 |
Published: {String(blogPost.isPublished)}
97 |
{blogPost.textContent}
98 |
99 | ); 100 | })} 101 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /app/example-3-missing-authorization-route-handler/[exampleType]/MissingAuthorizationApiRoute.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { useEffect, useState } from 'react'; 5 | import { BlogPost } from '../../../database/blogPosts'; 6 | import { MissingAuthorizationApiRouteResponseBodyGet } from '../../api/example-3-missing-authorization-route-handler/solution-1/route'; 7 | import LinkIfNotCurrent from '../../LinkIfNotCurrent'; 8 | 9 | type Props = { 10 | exampleType: string; 11 | }; 12 | 13 | export default function MissingAuthorizationApiRoute(props: Props) { 14 | const [error, setError] = useState(); 15 | const [blogPosts, setBlogPosts] = useState([]); 16 | 17 | if ( 18 | !props.exampleType || 19 | !/^(vulnerable-1|solution-\d)$/.test(props.exampleType) 20 | ) { 21 | notFound(); 22 | } 23 | 24 | useEffect(() => { 25 | async function fetchInitialData() { 26 | const response = await fetch( 27 | `/api/example-3-missing-authorization-route-handler/${props.exampleType}`, 28 | ); 29 | 30 | const data: MissingAuthorizationApiRouteResponseBodyGet = 31 | await response.json(); 32 | 33 | if ('error' in data) { 34 | setError(data.error); 35 | return; 36 | } 37 | 38 | setError(undefined); 39 | setBlogPosts(data.blogPosts); 40 | } 41 | 42 | fetchInitialData().catch(() => {}); 43 | }, [props.exampleType]); 44 | 45 | return ( 46 | <> 47 |

Missing Authorization - Route Handler

48 | 49 |
    50 |
  • 51 | 52 | Vulnerable 1 53 | {' '} 54 | - API code:{' '} 55 | 56 | pages/api/example-3-missing-authorization-route-handler/vulnerable.ts 57 | 58 |
  • 59 |
  • 60 | 61 | Vulnerable 2 62 | {' '} 63 | - API code:{' '} 64 | 65 | pages/api/example-3-missing-authorization-route-handler/vulnerable.ts 66 | 67 |
  • 68 |
  • 69 | 70 | Solution 1 71 | {' '} 72 | - API code:{' '} 73 | 74 | pages/api/example-3-missing-authorization-route-handler/solution-1.ts 75 | 76 |
  • 77 |
  • 78 | 79 | Solution 2 80 | {' '} 81 | - API code:{' '} 82 | 83 | pages/api/example-3-missing-authorization-route-handler/solution-2.ts 84 | 85 |
  • 86 |
87 | 88 |
89 | 90 |
91 | Below, a list of unpublished blog posts will appear for logged-in users 92 | - similar to a "Drafts" list in a CMS. 93 |
94 |
95 | Each unpublished blog post should only be visible for the owner of the 96 | post. 97 |
98 | 99 |

Unpublished Blog Posts

100 | 101 | {!!error &&
{error}
} 102 | 103 | {blogPosts.map((blogPost) => { 104 | return ( 105 |
106 |

{blogPost.title}

107 |
Published: {String(blogPost.isPublished)}
108 |
{blogPost.textContent}
109 |
110 | ); 111 | })} 112 | 113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /app/example-3-missing-authorization-route-handler/vulnerable-2/MissingAuthorizationApiRoute.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | import { BlogPost } from '../../../database/blogPosts'; 5 | import { User } from '../../../database/users'; 6 | import { MissingAuthorizationApiRouteResponseBodyGet } from '../../api/example-3-missing-authorization-route-handler/solution-1/route'; 7 | import LinkIfNotCurrent from '../../LinkIfNotCurrent'; 8 | 9 | type Props = { 10 | user: User | undefined; 11 | }; 12 | 13 | export default function MissingAuthorizationApiRoute(props: Props) { 14 | const [error, setError] = useState(); 15 | const [blogPosts, setBlogPosts] = useState([]); 16 | 17 | useEffect(() => { 18 | async function fetchInitialData() { 19 | const response = await fetch( 20 | '/api/example-3-missing-authorization-route-handler/vulnerable-1', 21 | ); 22 | 23 | const data: MissingAuthorizationApiRouteResponseBodyGet = 24 | await response.json(); 25 | 26 | if ('error' in data) { 27 | setError(data.error); 28 | return; 29 | } 30 | 31 | setError(undefined); 32 | setBlogPosts( 33 | // Filter to blog posts owned by the user 34 | // Vulnerability fixed? 35 | data.blogPosts.filter((blogPost: BlogPost) => { 36 | return blogPost.userId === props.user?.id; 37 | }), 38 | ); 39 | } 40 | 41 | fetchInitialData().catch(() => {}); 42 | }, [props.user]); 43 | 44 | return ( 45 | <> 46 |

Missing Authorization - Route Handler

47 | 48 |
    49 |
  • 50 | 51 | Vulnerable 1 52 | {' '} 53 | - API code:{' '} 54 | 55 | pages/api/example-3-missing-authorization-route-handler/vulnerable.ts 56 | 57 |
  • 58 |
  • 59 | 60 | Vulnerable 2 61 | {' '} 62 | - API code:{' '} 63 | 64 | pages/api/example-3-missing-authorization-route-handler/vulnerable.ts 65 | 66 |
  • 67 |
  • 68 | 69 | Solution 1 70 | {' '} 71 | - API code:{' '} 72 | 73 | pages/api/example-3-missing-authorization-route-handler/solution-1.ts 74 | 75 |
  • 76 |
  • 77 | 78 | Solution 2 79 | {' '} 80 | - API code:{' '} 81 | 82 | pages/api/example-3-missing-authorization-route-handler/solution-2.ts 83 | 84 |
  • 85 |
86 | 87 |
88 | 89 |
90 | Below, a list of unpublished blog posts will appear for logged-in users 91 | - similar to a "Drafts" list in a CMS. 92 |
93 |
94 | Each unpublished blog post should only be visible for the owner of the 95 | post. 96 |
97 | 98 |

Unpublished Blog Posts

99 | 100 | {!!error &&
{error}
} 101 | 102 | {blogPosts.map((blogPost) => { 103 | return ( 104 |
105 |

{blogPost.title}

106 |
Published: {String(blogPost.isPublished)}
107 |
{blogPost.textContent}
108 |
109 | ); 110 | })} 111 | 112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Examples of Broken Security with Next.js + Postgres.js 2 | 3 | Examples of common security mistakes causing broken authentication, broken authorization, secrets exposure, cross-site scripting and more. 4 | 5 |
6 | 7 |

Screenshot of the missing authentication example, where blog post content is incorrectly being shown to a user who is not logged in (all blog post content should be only visible to logged-in users)

8 |
9 | 10 |

11 | 12 |
13 | 14 |

Screenshot of the missing authorization example, where unpublished, private blog post content is incorrectly being exposed in the HTML to a user who is not the owner

15 |
16 | 17 |

18 | 19 |
20 | 21 |

Screenshot of the secrets exposure example, showing an API key being exposed

22 |
23 | 24 |

25 | 26 |
27 | 28 |

Screenshot of cross-site scripting example, showing an alert() triggered from an image with a broken src and an onerror attribute

29 |
30 | 31 |

32 | 33 | ## Setup 34 | 35 | Clone the repo and install the dependencies using pnpm: 36 | 37 | ```bash 38 | pnpm install 39 | ``` 40 | 41 | If you are on Windows, you may receive an error about `libpg-query` not being able to be installed. In this case, edit your `package.json` file to remove the 2 lines starting with `"@ts-safeql/eslint-plugin"` and `"libpg-query"` and retry the installation using the command above. 42 | 43 | ## Database Setup 44 | 45 | Copy the `.env.example` file to a new file called `.env` (ignored from Git) and fill in the necessary information. 46 | 47 | To install PostgreSQL on your computer, follow the instructions from the PostgreSQL step in [UpLeveled's System Setup Instructions](https://github.com/upleveled/system-setup/blob/master/readme.md). 48 | 49 | Then, connect to the built-in `postgres` database as administrator in order to create the database: 50 | 51 | **Windows** 52 | 53 | If it asks for a password, use `postgres`. 54 | 55 | ```bash 56 | psql -U postgres 57 | ``` 58 | 59 | **macOS** 60 | 61 | ```bash 62 | psql postgres 63 | ``` 64 | 65 | **Linux** 66 | 67 | ```bash 68 | sudo -u postgres psql 69 | ``` 70 | 71 | Once you have connected, run the following to create the database: 72 | 73 | ```sql 74 | CREATE DATABASE security_vulnerability_examples_next_js_postgres; 75 | 76 | CREATE USER security_vulnerability_examples_next_js_postgres 77 | WITH 78 | ENCRYPTED PASSWORD 'security_vulnerability_examples_next_js_postgres'; 79 | 80 | GRANT ALL PRIVILEGES ON DATABASE security_vulnerability_examples_next_js_postgres TO security_vulnerability_examples_next_js_postgres; 81 | ``` 82 | 83 | Quit `psql` using the following command: 84 | 85 | ```bash 86 | \q 87 | ``` 88 | 89 | On Linux, you will also need to create a Linux system user with a name matching the user name you used in the database. It will prompt you to create a password for the user - choose the same password as for the database above. 90 | 91 | ```bash 92 | sudo adduser security_vulnerability_examples_next_js_postgres 93 | ``` 94 | 95 | Once you're ready to use the new user, reconnect using the following command. 96 | 97 | **Windows and macOS:** 98 | 99 | ```bash 100 | psql -U security_vulnerability_examples_next_js_postgres security_vulnerability_examples_next_js_postgres 101 | ``` 102 | 103 | **Linux:** 104 | 105 | ```bash 106 | sudo -u security_vulnerability_examples_next_js_postgres psql -U security_vulnerability_examples_next_js_postgres security_vulnerability_examples_next_js_postgres 107 | ``` 108 | 109 | ## Running Migrations 110 | 111 | To set up the structure and the content of the database, run the migrations using Ley: 112 | 113 | ```bash 114 | pnpm migrate up 115 | ``` 116 | 117 | To reverse the last single migration, run: 118 | 119 | ```bash 120 | pnpm migrate down 121 | ``` 122 | 123 | ## Run Dev Server 124 | 125 | Run the Next.js dev server with: 126 | 127 | ```bash 128 | pnpm dev 129 | ``` 130 | --------------------------------------------------------------------------------