').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 |
8 |
9 | Example 1: Missing Authentication - Route Handler
10 |
11 |
12 |
13 |
14 | Example 2: Missing Authentication - Server Component
15 |
16 |
17 |
18 |
19 | Example 3: Missing Authorization - Route Handler
20 |
21 |
22 |
23 |
24 | Example 4: Missing Authorization - Server Component
25 |
26 |
27 |
28 |
29 | Example 5: Data Exposure
30 |
31 |
32 |
33 |
34 | Example 6: Cross-Site Scripting (XSS)
35 |
36 |
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 |
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 |
36 | Home
37 |
38 |
42 | Example 1
43 |
44 |
45 |
49 | Example 2
50 |
51 |
52 |
56 | Example 3
57 |
58 |
59 |
63 | Example 4
64 |
65 |
69 | Example 5
70 |
71 |
72 |
76 | Example 6
77 |
78 |
79 |
80 | {!user ? (
81 |
Login
82 | ) : (
83 | <>
84 |
Logout
85 |
Logged in as {user.username}
86 | >
87 | )}
88 |
89 |
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 |
--------------------------------------------------------------------------------