Skip to content

Commit 8b182af

Browse files
committed
security update
1 parent b8f55c0 commit 8b182af

File tree

6 files changed

+166
-29
lines changed

6 files changed

+166
-29
lines changed

app/[id]/page.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ const sanitizeTitleForTab = (title: string) => {
2525
return sanitizedTitle.trim() || "Henry's Notes";
2626
};
2727

28+
// Add this helper function at the top of your file
29+
const sanitizeErrorMessage = (status: number, message?: string): string => {
30+
const defaultMessages: { [key: number]: string } = {
31+
400: "Bad Request",
32+
401: "Unauthorized",
33+
403: "Forbidden",
34+
404: "Paste not found",
35+
500: "Internal server error",
36+
};
37+
38+
// Use default message if available, otherwise use sanitized custom message
39+
const errorMessage = defaultMessages[status] ||
40+
(message ? message.replace(/[^a-zA-Z0-9\s.,!?-]/g, '') : "An error occurred");
41+
42+
return `\`\`\`\nError ${status}: ${errorMessage}\n\`\`\``;
43+
};
44+
2845
export default function Paste() {
2946
const { id } = useParams();
3047
const [title, setTitle] = useState('');
@@ -60,14 +77,14 @@ export default function Paste() {
6077
setContent(data.content);
6178
}
6279
} else {
63-
// Handle error based on the response status and message
80+
// Use the sanitized error message handler
6481
setTitle("Error");
65-
setContent(`\`\`\`\n${response.status}: ${data.error || "```\n500: We were unable to fetch this paste.\n```"}\n\`\`\``);
82+
setContent(sanitizeErrorMessage(response.status, data.error));
6683
}
6784
} catch (error) {
68-
// Catch any network or unexpected errors
85+
// Handle network or unexpected errors
6986
setTitle("Error");
70-
setContent("```\n500: We were unable to fetch this paste.\n```");
87+
setContent(sanitizeErrorMessage(500));
7188
} finally {
7289
setLoading(false);
7390
}

app/api/paste/route.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,37 @@ import clientPromise from '../../../utils/mongodb';
55
import { generateUniqueId } from '../../../utils/slugUtils';
66
import { sendDiscordNotification } from '../../../utils/discord';
77

8+
const validEncryptionMethods = ['key', 'password', null] as const;
9+
type EncryptionMethod = typeof validEncryptionMethods[number];
10+
11+
const MIN_SIZE_BYTES = 1;
12+
const MAX_SIZE_BYTES = 400 * 1024; // 400 KB
13+
const MAX_TITLE_LENGTH = 100;
14+
const MIN_TITLE_LENGTH = 1;
15+
816
export async function POST(request: Request) {
917
try {
1018
const { title, content, encryptionMethod } = await request.json();
1119

1220
// Validate incoming data
13-
if (!title || typeof title !== 'string' || title.trim() === '') {
14-
return NextResponse.json({ error: 'Invalid title. Title cannot be empty.' }, { status: 400 });
21+
if (!title || typeof title !== 'string' ||
22+
title.length < MIN_TITLE_LENGTH ||
23+
title.length > MAX_TITLE_LENGTH) {
24+
return NextResponse.json({
25+
error: `Title must be between ${MIN_TITLE_LENGTH} and ${MAX_TITLE_LENGTH} characters.`
26+
}, { status: 400 });
1527
}
1628
if (!content || typeof content !== 'string' || content.trim() === '') {
1729
return NextResponse.json({ error: 'Invalid content. Content cannot be empty.' }, { status: 400 });
1830
}
19-
if (title.length > 100) {
20-
return NextResponse.json({ error: 'Title is too long. Maximum length is 100 characters.' }, { status: 400 });
21-
}
2231

2332
const fullPaste = `${title}\n${content}`;
2433
const pasteSize = new Blob([fullPaste]).size;
2534

26-
const MAX_SIZE_BYTES = 400 * 1024; // 400 KB in bytes
27-
28-
if (pasteSize > MAX_SIZE_BYTES) {
29-
return NextResponse.json({ error: `Paste exceeds ${(MAX_SIZE_BYTES / 1024)} KB size limit. Current size is ${(pasteSize / 1024).toFixed(2)} KB.` }, { status: 400 });
35+
if (pasteSize < MIN_SIZE_BYTES || pasteSize > MAX_SIZE_BYTES) {
36+
return NextResponse.json({
37+
error: `Content size must be between ${MIN_SIZE_BYTES} and ${MAX_SIZE_BYTES/1024} KB.`
38+
}, { status: 400 });
3039
}
3140

3241
const client = await clientPromise;
@@ -40,6 +49,10 @@ export async function POST(request: Request) {
4049
return NextResponse.json({ error: 'ID collision occurred. Please try again.' }, { status: 500 });
4150
}
4251

52+
if (!validEncryptionMethods.includes(encryptionMethod as EncryptionMethod)) {
53+
return NextResponse.json({ error: 'Invalid encryption method' }, { status: 400 });
54+
}
55+
4356
// Insert the paste into the MongoDB database
4457
const result = await db.collection('pastes').insertOne({
4558
id,

lib/authOptions.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// lib/authOptions.ts
22

3-
import { NextAuthOptions } from "next-auth";
3+
import type { NextAuthOptions } from "next-auth";
44
import GithubProvider from "next-auth/providers/github";
55

66
// Extend the Session interface to include the id property
@@ -23,16 +23,20 @@ const allowedUsers = process.env.ALLOWED_USERS?.split(",") || [];
2323
export const authOptions: NextAuthOptions = {
2424
providers: [
2525
GithubProvider({
26-
clientId: process.env.GITHUB_ID!,
27-
clientSecret: process.env.GITHUB_SECRET!,
28-
authorization: { params: { scope: "read:user" } },
26+
clientId: process.env.GITHUB_ID as string,
27+
clientSecret: process.env.GITHUB_SECRET as string,
2928
}),
3029
],
3130
secret: process.env.NEXTAUTH_SECRET,
3231
pages: {
3332
signIn: "/auth/signin", // Custom sign-in page
3433
error: "/auth/signin", // Redirect to sign-in page on error
3534
},
35+
session: {
36+
// Session will expire in 8 hours
37+
maxAge: 8 * 60 * 60, // 8 hours in seconds
38+
updateAge: 60 * 60, // Update session every 1 hour
39+
},
3640
callbacks: {
3741
async signIn({ profile }) {
3842
const githubProfile = profile as GitHubProfile;

middleware.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,55 @@
22

33
import { withAuth } from "next-auth/middleware";
44
import { NextResponse } from "next/server";
5+
import { rateLimit } from './utils/rateLimit';
56

67
// Load allowed users from the environment variables
78
const allowedUsers = process.env.ALLOWED_USERS?.split(",") || [];
89

10+
// Create rate limiters for different endpoints
11+
const apiLimiter = rateLimit({
12+
interval: 60 * 1000, // 1 minute
13+
uniqueTokenPerInterval: 500
14+
});
15+
16+
const adminLimiter = rateLimit({
17+
interval: 60 * 1000,
18+
uniqueTokenPerInterval: 100
19+
});
20+
921
export default withAuth(
10-
function middleware(req) {
22+
async function middleware(req) {
1123
const { pathname, origin } = req.nextUrl;
24+
const ip = req.ip ?? '127.0.0.1';
1225

13-
if (pathname.startsWith("/admin")) {
14-
const token = req.nextauth.token;
26+
try {
27+
// Apply rate limiting based on path
28+
if (pathname.startsWith("/api/admin")) {
29+
await adminLimiter.check(ip, 100); // Stricter limit for admin routes
30+
} else if (pathname.startsWith("/api")) {
31+
await apiLimiter.check(ip, 30); // General API rate limit
32+
}
1533

16-
// Check if the user ID is allowed to access
17-
if (!token || typeof token.id !== "number" || !allowedUsers.includes(token.id.toString())) {
18-
const signInUrl = new URL("/auth/signin", origin);
19-
signInUrl.searchParams.set("callbackUrl", req.url);
20-
return NextResponse.redirect(signInUrl);
34+
// Existing admin authentication logic
35+
if (pathname.startsWith("/admin")) {
36+
const token = req.nextauth.token;
37+
if (!token || typeof token.id !== "number" || !allowedUsers.includes(token.id.toString())) {
38+
const signInUrl = new URL("/auth/signin", origin);
39+
signInUrl.searchParams.set("callbackUrl", req.url);
40+
return NextResponse.redirect(signInUrl);
41+
}
2142
}
43+
44+
return NextResponse.next();
45+
} catch {
46+
return new NextResponse('Too Many Requests', { status: 429 });
2247
}
2348
}
2449
);
2550

2651
export const config = {
27-
matcher: ["/admin/:path*"],
52+
matcher: [
53+
"/admin/:path*",
54+
"/api/:path*"
55+
],
2856
};

next.config.mjs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,42 @@
11
/** @type {import('next').NextConfig} */
22
const nextConfig = {
3-
};
4-
5-
export default nextConfig;
3+
async headers() {
4+
return [
5+
{
6+
source: '/:path*',
7+
headers: [
8+
{
9+
key: 'X-DNS-Prefetch-Control',
10+
value: 'on'
11+
},
12+
{
13+
key: 'Strict-Transport-Security',
14+
value: 'max-age=31536000; includeSubDomains'
15+
},
16+
{
17+
key: 'X-Frame-Options',
18+
value: 'SAMEORIGIN'
19+
},
20+
{
21+
key: 'X-Content-Type-Options',
22+
value: 'nosniff'
23+
},
24+
{
25+
key: 'Referrer-Policy',
26+
value: 'strict-origin-when-cross-origin'
27+
},
28+
{
29+
key: 'Permissions-Policy',
30+
value: 'camera=(), microphone=(), geolocation=()'
31+
},
32+
{
33+
key: 'Content-Security-Policy',
34+
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'"
35+
}
36+
]
37+
}
38+
];
39+
}
40+
};
41+
42+
export default nextConfig;

utils/rateLimit.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export interface RateLimitConfig {
2+
interval: number;
3+
uniqueTokenPerInterval: number;
4+
}
5+
6+
interface TokenBucket {
7+
tokens: number;
8+
lastRefill: number;
9+
}
10+
11+
export function rateLimit(config: RateLimitConfig) {
12+
const tokenBuckets = new Map<string, TokenBucket>();
13+
14+
return {
15+
check: async (key: string, limit: number) => {
16+
const now = Date.now();
17+
let bucket = tokenBuckets.get(key);
18+
19+
if (!bucket) {
20+
bucket = { tokens: limit, lastRefill: now };
21+
tokenBuckets.set(key, bucket);
22+
} else {
23+
// Refill tokens based on time passed
24+
const timePassed = now - bucket.lastRefill;
25+
const refillAmount = Math.floor(timePassed / config.interval) * limit;
26+
bucket.tokens = Math.min(limit, bucket.tokens + refillAmount);
27+
bucket.lastRefill = now;
28+
}
29+
30+
if (bucket.tokens > 0) {
31+
bucket.tokens--;
32+
return true;
33+
}
34+
35+
throw new Error('Rate limit exceeded');
36+
}
37+
};
38+
}

0 commit comments

Comments
 (0)