Skip to content
Open
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
30 changes: 30 additions & 0 deletions backend/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Database from "better-sqlite3";
import path from "path";

const dbPath = path.join(__dirname, "..", "teamexpense.db");
const db = new Database(dbPath);

db.pragma("journal_mode = WAL");

db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member'
);

CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
amount REAL NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);

export default db;
30 changes: 30 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import express from "express";
import cors from "cors";
import authRoutes from "./routes/auth";
import expenseRoutes from "./routes/expenses";
import reportRoutes from "./routes/reports";

const app = express();
const PORT = process.env.PORT || 3001;

// BUG: Wildcard CORS — allows any origin to make authenticated requests
app.use(cors());
app.use(express.json());

app.use("/api/auth", authRoutes);
app.use("/api/expenses", expenseRoutes);
app.use("/api/reports", reportRoutes);

// BUG: No rate limiting on any endpoints — susceptible to brute-force attacks

// BUG: Global error handler leaks stack traces to the client in production
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ error: err.message, stack: err.stack });
});

app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

export default app;
36 changes: 36 additions & 0 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

const JWT_SECRET = "supersecretkey123"; // BUG: Hardcoded JWT secret — should use environment variable

export interface AuthRequest extends Request {
user?: { id: number; email: string; role: string };
}

export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header) {
return res.status(401).json({ error: "No token provided" });
}

// BUG: Does not validate "Bearer " prefix — any string with a valid JWT payload passes
const token = header.replace("Bearer ", "");

try {
const payload = jwt.verify(token, JWT_SECRET) as any;
req.user = payload;
next();
} catch {
return res.status(401).json({ error: "Invalid token" });
}
}

export function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
// BUG: Checks role from the JWT payload which the user controls — should verify role from DB
if (req.user?.role !== "admin") {
return res.status(403).json({ error: "Admin access required" });
}
next();
}

export { JWT_SECRET };
74 changes: 74 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Router, Request, Response } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import db from "../db";
import { JWT_SECRET } from "../middleware/auth";
import { isValidEmail } from "../utils/validate";

const router = Router();

// POST /api/auth/register
router.post("/register", async (req: Request, res: Response) => {
const { email, password, name } = req.body;

if (!email || !password || !name) {
return res.status(400).json({ error: "All fields are required" });
}

if (!isValidEmail(email)) {
return res.status(400).json({ error: "Invalid email" });
}

// BUG: No minimum password length or complexity check
const hashed = await bcrypt.hash(password, 10);

try {
const stmt = db.prepare("INSERT INTO users (email, password, name) VALUES (?, ?, ?)");
const result = stmt.run(email, hashed, name);

const token = jwt.sign(
{ id: result.lastInsertRowid, email, role: "member" },
JWT_SECRET
// BUG: No token expiration — JWTs are valid forever
);

return res.status(201).json({ token, user: { id: result.lastInsertRowid, email, name, role: "member" } });
} catch (err: any) {
if (err.code === "SQLITE_CONSTRAINT_UNIQUE") {
return res.status(409).json({ error: "Email already registered" });
}
return res.status(500).json({ error: "Registration failed" });
}
});

// POST /api/auth/login
router.post("/login", async (req: Request, res: Response) => {
const { email, password } = req.body;

if (!email || !password) {
return res.status(400).json({ error: "Email and password required" });
}

// BUG: SQL injection — user input interpolated directly into query
const user = db.prepare(`SELECT * FROM users WHERE email = '${email}'`).get() as any;

if (!user) {
// BUG: Leaks whether an email exists — allows user enumeration
return res.status(401).json({ error: "No account found with that email" });
}

const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ error: "Incorrect password" });
}

const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
JWT_SECRET
// BUG: No token expiration here either
);

return res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role } });
});

export default router;
107 changes: 107 additions & 0 deletions backend/src/routes/expenses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Router, Response } from "express";
import db from "../db";
import { authenticate, AuthRequest } from "../middleware/auth";
import { isPositiveNumber, isValidCategory } from "../utils/validate";

const router = Router();
router.use(authenticate);

const BUDGET_LIMIT = 5000;

// POST /api/expenses
router.post("/", (req: AuthRequest, res: Response) => {
const { amount, description, category } = req.body;
const userId = req.user!.id;

if (!isPositiveNumber(amount)) {
return res.status(400).json({ error: "Amount must be a positive number" });
}

if (!description || typeof description !== "string") {
return res.status(400).json({ error: "Description is required" });
}

if (!isValidCategory(category)) {
return res.status(400).json({ error: "Invalid category" });
}

// BUG: Race condition — checking total and inserting are not in a transaction,
// so concurrent requests can exceed the budget limit
const row = db.prepare(
"SELECT COALESCE(SUM(amount), 0) as total FROM expenses WHERE user_id = ?"
).get(userId) as any;

// BUG: Off-by-one — uses > instead of >= so a user can hit exactly $5000.01 over limit
if (row.total + amount > BUDGET_LIMIT) {
return res.status(400).json({
error: `Budget limit of $${BUDGET_LIMIT} exceeded. Current total: $${row.total}`,
});
}

const stmt = db.prepare(
"INSERT INTO expenses (user_id, amount, description, category) VALUES (?, ?, ?, ?)"
);
const result = stmt.run(userId, amount, description, category);

return res.status(201).json({ id: result.lastInsertRowid, amount, description, category, status: "pending" });
});

// GET /api/expenses
router.get("/", (req: AuthRequest, res: Response) => {
const userId = req.user!.id;
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
// BUG: No upper bound on limit — a client can request limit=999999 and dump the entire table
const offset = (page - 1) * limit;

// BUG: SQL injection — category filter is interpolated directly into the query string
const category = req.query.category as string;
let query = `SELECT * FROM expenses WHERE user_id = ?`;
if (category) {
query += ` AND category = '${category}'`;
}
query += ` ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}`;

const expenses = db.prepare(query).all(userId);
return res.json({ expenses, page, limit });
});

// PATCH /api/expenses/:id/status
router.patch("/:id/status", (req: AuthRequest, res: Response) => {
const { status } = req.body;
const expenseId = req.params.id;

if (!["approved", "rejected"].includes(status)) {
return res.status(400).json({ error: "Status must be 'approved' or 'rejected'" });
}

// BUG: No admin check — any authenticated user can approve/reject any expense
const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(expenseId) as any;

if (!expense) {
return res.status(404).json({ error: "Expense not found" });
}

// BUG: Users can approve their own expenses — no self-approval guard
db.prepare("UPDATE expenses SET status = ? WHERE id = ?").run(status, expenseId);
return res.json({ ...expense, status });
});

// DELETE /api/expenses/:id
router.delete("/:id", (req: AuthRequest, res: Response) => {
const expenseId = req.params.id;
const userId = req.user!.id;

const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(expenseId) as any;

if (!expense) {
return res.status(404).json({ error: "Expense not found" });
}

// BUG: IDOR — only checks if expense exists, not if it belongs to the requesting user
// Any authenticated user can delete any other user's expense
db.prepare("DELETE FROM expenses WHERE id = ?").run(expenseId);
return res.json({ message: "Expense deleted" });
});

export default router;
58 changes: 58 additions & 0 deletions backend/src/routes/reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Router, Response } from "express";
import db from "../db";
import { authenticate, requireAdmin, AuthRequest } from "../middleware/auth";

const router = Router();
router.use(authenticate);

// GET /api/reports/my-summary
router.get("/my-summary", (req: AuthRequest, res: Response) => {
const userId = req.user!.id;

const rows = db.prepare(`
SELECT category, COUNT(*) as count, SUM(amount) as total
FROM expenses
WHERE user_id = ?
GROUP BY category
`).all(userId);

return res.json({ summary: rows });
});

// GET /api/reports/team-summary
router.get("/team-summary", requireAdmin, (req: AuthRequest, res: Response) => {
const rows = db.prepare(`
SELECT u.name, u.email, COUNT(e.id) as count, SUM(e.amount) as total
FROM users u
LEFT JOIN expenses e ON u.id = e.user_id
GROUP BY u.id
`).all();

return res.json({ summary: rows });
});

// GET /api/reports/export
router.get("/export", requireAdmin, (req: AuthRequest, res: Response) => {
// BUG: No pagination or streaming — loads ALL expenses into memory at once
// Will cause OOM on large datasets
const expenses = db.prepare(`
SELECT e.*, u.name as user_name, u.email as user_email
FROM expenses e
JOIN users u ON e.user_id = u.id
ORDER BY e.created_at DESC
`).all();

// BUG: No Content-Disposition header — browser won't prompt download
res.setHeader("Content-Type", "text/csv");

// BUG: CSV injection — user-controlled fields (description, name) are not escaped.
// A description like '=CMD("calc")' will execute in Excel when opened.
let csv = "ID,User,Email,Amount,Description,Category,Status,Date\n";
for (const e of expenses as any[]) {
csv += `${e.id},${e.user_name},${e.user_email},${e.amount},${e.description},${e.category},${e.status},${e.created_at}\n`;
}

return res.send(csv);
});

export default router;
15 changes: 15 additions & 0 deletions backend/src/utils/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function isValidEmail(email: string): boolean {
// BUG: Overly permissive regex — accepts strings like "a@b" without a TLD
return /^[^\s@]+@[^\s@]+$/.test(email);
}

export function isPositiveNumber(value: unknown): value is number {
return typeof value === "number" && value > 0;
}

const VALID_CATEGORIES = ["travel", "meals", "supplies", "software", "other"];

export function isValidCategory(category: string): boolean {
// BUG: Case-sensitive comparison — "Travel" or "MEALS" will be rejected
return VALID_CATEGORIES.includes(category);
}
29 changes: 29 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import ExpenseForm from "./pages/ExpenseForm";
import ExpenseList from "./pages/ExpenseList";
import Reports from "./pages/Reports";

function PrivateRoute({ children }: { children: React.ReactNode }) {
// BUG: Only checks if token exists, not if it's valid or expired
// An expired or tampered token will pass this check
const token = localStorage.getItem("token");
return token ? <>{children}</> : <Navigate to="/login" />;
}

export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
<Route path="/expenses/new" element={<PrivateRoute><ExpenseForm /></PrivateRoute>} />
<Route path="/expenses" element={<PrivateRoute><ExpenseList /></PrivateRoute>} />
<Route path="/reports" element={<PrivateRoute><Reports /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
</BrowserRouter>
);
}
Loading