Skip to content
This repository was archived by the owner on Feb 1, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions SECURITY_AUDIT_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Coindrop.to Security Audit Report

## 🚨 CRITICAL SECURITY ISSUES
(Issues requiring immediate attention)

**Issue:** Stored Cross-Site Scripting (XSS)
**Location:** `pages/api/createPiggybank.ts` and `components/PublicPiggybankPage/PublicPiggybankPage.tsx`
**Risk:** An attacker can create a piggybank with a malicious `website` URL (e.g., `javascript:alert(cookies)`). When a user visits the piggybank page and clicks the website link, the malicious script executes. This could lead to account takeover or data theft.
**Fix:** Validate the `website` field in `createPiggybank.ts` using `validator.isURL` and ensure it uses `http` or `https`. Additionally, sanitize the link in the frontend.
**Effort:** Low (< 1 hour)

---

### ⚠️ SECURITY RECOMMENDATIONS
(Important but not critical)

**Issue:** Weak Admin Authentication
**Location:** `server/middleware/requireAdminPassword.ts`
**Impact:** The admin endpoints rely on a shared password sent in the request body. If the `ADMIN_PASSWORD` env var is weak or leaked, all admin functions are compromised. It also lacks accountability.
**Fix:** Implement role-based access control (RBAC) using Firebase Auth (e.g., set custom claims for admins) instead of a shared password.
**Effort:** Medium (2-4 hours)

**Issue:** Missing Security Headers
**Location:** `next.config.js`
**Impact:** The application is missing standard HTTP security headers (HSTS, X-Frame-Options, X-XSS-Protection, etc.), making it more susceptible to clickjacking, MITM attacks, and XSS.
**Fix:** Configure `headers` in `next.config.js` to return appropriate security headers.
**Effort:** Low (< 1 hour)

**Issue:** Missing Rate Limiting
**Location:** API Routes
**Impact:** API endpoints are vulnerable to abuse (spam creation of piggybanks, brute force).
**Fix:** Implement rate limiting middleware (e.g., `rate-limiter-flexible` with Redis or memory) for sensitive endpoints.
**Effort:** Medium (2-4 hours)

---

### 💡 HIGH-VALUE IMPROVEMENTS
(Ranked by impact-to-effort ratio)

**#1: Add Input Validation**
**Impact:** Prevents bad data from entering the database and reduces runtime errors.
**Implementation:** Use `zod` schema validation in API routes (already installed). Validate `piggybankData` structure fully.
**Effort:** Low
**Priority:** High

**#2: Upgrade Dependencies**
**Impact:** access to security fixes and performance improvements.
**Implementation:** Update `firebase` to v9 modular SDK completely (some parts seem mixed), and `next` to a newer version (carefully).
**Effort:** Medium
**Priority:** Medium

---

### 📊 SUMMARY
- Total critical issues: 1
- Total security recommendations: 3
- Total quick wins identified: 1
- Estimated total time for all quick wins: 2 hours

---

### 🎯 RECOMMENDED ACTION PLAN

1. Fix the Stored XSS in `createPiggybank.ts`.
2. Add Security Headers in `next.config.js`.
3. Implement `zod` validation for API inputs.
4. Replace shared admin password with Firebase Custom Claims.
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module.exports = {
moduleNameMapper: {
'\\.(scss|css|less)$': '<rootDir>/__mocks__/styleMock.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js',
'^uuid$': require.resolve('uuid'),
},
setupFiles: [
'<rootDir>/src/tests/setup.js',
Expand Down
33 changes: 33 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,37 @@ module.exports = withBundleAnalyzer({
images: {
domains: ['storage.googleapis.com'],
},
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
];
},
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
"vercel": "^28.1.0"
},
"engines": {
"node": "^24.11.1"
"node": ">=18.0.0"
},
"packageManager": "yarn@1.22.22"
}
83 changes: 83 additions & 0 deletions pages/api/createPiggybank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Mock dependencies before imports
jest.mock('@google-cloud/storage', () => ({
Storage: jest.fn().mockImplementation(() => ({
bucket: jest.fn().mockReturnValue({
file: jest.fn().mockReturnValue({
move: jest.fn(),
}),
}),
})),
}));

jest.mock('../../utils/auth/firebaseAdmin', () => ({
db: {
collection: jest.fn(),
},
}));

jest.mock('../../utils/storage/image-paths', () => ({
piggybankImageStoragePath: jest.fn(),
}));

jest.mock('../page-slugs.json', () => [], { virtual: true });

import { createPiggybank } from './createPiggybank';

Check failure on line 24 in pages/api/createPiggybank.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

Import in body of module; reorder to top
import { db } from '../../utils/auth/firebaseAdmin';

Check failure on line 25 in pages/api/createPiggybank.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

Import in body of module; reorder to top

describe('createPiggybank API', () => {
let req: any;
let res: any;

beforeEach(() => {
jest.clearAllMocks();

req = {
body: {
newPiggybankName: 'testpiggy',
piggybankData: {
name: 'Test Piggy',
website: 'https://valid.com',
},
},
headers: {
uid: 'user123',
},
};

res = {
status: jest.fn().mockReturnThis(),
end: jest.fn(),
send: jest.fn(),
};

// Setup default successful DB mocks
const mockDoc = {
get: jest.fn().mockResolvedValue({ exists: false }),
set: jest.fn().mockResolvedValue(true),
};

const mockQuerySnapshot = {
size: 0,
empty: true,
};

(db.collection as jest.Mock).mockReturnValue({
doc: jest.fn().mockReturnValue(mockDoc),
where: jest.fn().mockReturnValue({
get: jest.fn().mockResolvedValue(mockQuerySnapshot),
}),
});
});

it('should return 200 for valid input', async () => {
await createPiggybank(req, res);
expect(res.status).toHaveBeenCalledWith(200);
});

it('should return 400 for malicious website URL', async () => {
req.body.piggybankData.website = 'javascript:alert(1)';

Check failure on line 78 in pages/api/createPiggybank.test.ts

View workflow job for this annotation

GitHub Actions / build (24.x)

Script URL is a form of eval
await createPiggybank(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith('Website URL is invalid.');
});
});
17 changes: 16 additions & 1 deletion pages/api/createPiggybank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import nc from 'next-connect';
import { Storage } from '@google-cloud/storage';
import { NextApiRequest, NextApiResponse } from 'next';
import { v4 as uuidV4 } from 'uuid';
import { z } from 'zod';
import requireFirebaseToken from '../../server/middleware/requireFirebaseToken';
import { db } from '../../utils/auth/firebaseAdmin';
import { maxPiggybanksPerUser, piggybankPathRegex } from '../../src/settings';
Expand Down Expand Up @@ -46,6 +47,16 @@ function isNameValid(piggybankName: string): Error | true {
return true;
}

const invalidWebsiteErrorMessage = 'Website URL is invalid.';
function isWebsiteValid(website?: string): Error | true {
if (!website) return true;
const result = z.string().url().startsWith('http').safeParse(website);
if (!result.success) {
throw new Error(invalidWebsiteErrorMessage);
}
return true;
}

async function renameAvatarFile({ ownerUid, oldPiggybankName, oldAvatarStorageId, newPiggybankName, newAvatarStorageId }: {
ownerUid: string
oldPiggybankName: string
Expand All @@ -67,7 +78,7 @@ type ReqBody = {
piggybankData?: PublicPiggybankDataType
}

const createPiggybank = async (req: NextApiRequest, res: NextApiResponse) => {
export const createPiggybank = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const {
oldPiggybankName,
Expand All @@ -83,6 +94,7 @@ const createPiggybank = async (req: NextApiRequest, res: NextApiResponse) => {
isPiggybankNameNonexistant(newPiggybankName),
isUserUnderPiggybankLimit(uid),
isNameValid(newPiggybankName),
isWebsiteValid(piggybankData?.website),
]);
const newPiggybankData: PublicPiggybankDataType = {
...piggybankData,
Expand All @@ -104,6 +116,9 @@ const createPiggybank = async (req: NextApiRequest, res: NextApiResponse) => {
if (error.message === nameInvalidErrorMessage) {
return res.status(400).send(nameInvalidErrorMessage);
}
if (error.message === invalidWebsiteErrorMessage) {
return res.status(400).send(invalidWebsiteErrorMessage);
}
return res.status(500).end();
}
};
Expand Down
Loading