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
10 changes: 7 additions & 3 deletions apps/api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ import { reviewRoutes } from "./routes/reviewRoutes.js";
import { messageRoutes } from "./routes/messageRoutes.js";
import { notificationRoutes } from "./routes/notificationRoutes.js";
import { uploadRoutes } from "./routes/uploadRoutes.js";
import { searchRoutes } from "./routes/searchRoutes.js";
import { createSearchRoutes } from "./routes/searchRoutes.js";
import { adminRoutes } from "./routes/adminRoutes.js";
import { env } from "./config/env.js";

export function createApp() {
const app = express();

app.use(helmet());
app.use(cors());

const corsOrigin = process.env.CORS_ORIGIN ?? "*";
app.use(cors({ origin: corsOrigin, credentials: true }));

app.use(express.json());
app.use(apiLimiter);

Expand All @@ -36,7 +40,7 @@ export function createApp() {
app.use("/api/messages", messageRoutes);
app.use("/api/notifications", notificationRoutes);
app.use("/api/uploads", uploadRoutes);
app.use("/api/search", searchRoutes);
app.use("/api/search", createSearchRoutes());
app.use("/api/admin", adminRoutes);

app.use(errorHandler);
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/controllers/searchController.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ok } from "../utils/response.js";
import { globalSearch } from "../services/searchService.js";
import { sanitizeSearchQuery } from "../validators/search.js";

export async function search(req, res) {
return ok(res, await globalSearch(req.query.q ?? ""));
// req.query has already been validated by middleware;
// sanitize as a defense-in-depth measure
const q = sanitizeSearchQuery(req.query.q);
return ok(res, await globalSearch(q));
}
4 changes: 3 additions & 1 deletion apps/api/src/controllers/userController.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { ok } from "../utils/response.js";
import { createUser, listUsers } from "../services/userService.js";
import { createUserSchema } from "../validators/user.js";

export async function getUsers(req, res) {
return ok(res, await listUsers());
}

export async function postUser(req, res) {
return ok(res, await createUser(req.body), 201);
const payload = createUserSchema.parse(req.body);
return ok(res, await createUser(payload), 201);
}
11 changes: 11 additions & 0 deletions apps/api/src/middleware/admin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { fail } from "../utils/response.js";
import { authMiddleware } from "./auth.js";

export function adminMiddleware(req, res, next) {
authMiddleware(req, res, () => {
if (req.user?.role !== "admin") {
return fail(res, "Forbidden: admin access required", 403);
}
return next();
});
}
20 changes: 20 additions & 0 deletions apps/api/src/middleware/searchRateLimit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import rateLimit from "express-rate-limit";

/**
* Create a search-specific rate limiter.
* Factory function ensures a fresh limiter instance per app,
* preventing state leak across test runs.
* 20 requests per minute per IP.
*/
export function createSearchLimiter() {
return rateLimit({
windowMs: 60 * 1000, // 1 minute
limit: 20,
standardHeaders: "draft-7",
legacyHeaders: false,
message: {
success: false,
message: "Too many search requests. Please try again later."
}
});
}
22 changes: 22 additions & 0 deletions apps/api/src/middleware/validate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ZodError } from "zod";
import { fail } from "../utils/response.js";

/**
* Middleware factory: validates req.query against a Zod schema.
* On success, replaces req.query with the parsed (and default-filled) result.
* On failure, responds 400 with the first error message.
*/
export function validateQuery(schema) {
return (req, res, next) => {
try {
req.query = schema.parse(req.query);
next();
} catch (err) {
if (err instanceof ZodError) {
const message = err.errors[0]?.message ?? "Invalid query parameters";
return fail(res, message, 400);
}
return fail(res, "Invalid query parameters", 400);
}
};
}
4 changes: 2 additions & 2 deletions apps/api/src/routes/adminRoutes.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Router } from "express";
import { metrics } from "../controllers/adminController.js";
import { authMiddleware } from "../middleware/auth.js";
import { adminMiddleware } from "../middleware/admin.js";

export const adminRoutes = Router();

adminRoutes.use(authMiddleware);
adminRoutes.use(adminMiddleware);
adminRoutes.get("/metrics", metrics);
3 changes: 2 additions & 1 deletion apps/api/src/routes/jobRoutes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Router } from "express";
import { getJobs, postJob } from "../controllers/jobController.js";
import { authMiddleware } from "../middleware/auth.js";

export const jobRoutes = Router();

jobRoutes.get("/", getJobs);
jobRoutes.post("/", postJob);
jobRoutes.post("/", authMiddleware, postJob);
2 changes: 2 additions & 0 deletions apps/api/src/routes/paymentRoutes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Router } from "express";
import { createPayment } from "../controllers/paymentController.js";
import { authMiddleware } from "../middleware/auth.js";

export const paymentRoutes = Router();

paymentRoutes.use(authMiddleware);
paymentRoutes.post("/", createPayment);
12 changes: 9 additions & 3 deletions apps/api/src/routes/searchRoutes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Router } from "express";
import { search } from "../controllers/searchController.js";
import { validateQuery } from "../middleware/validate.js";
import { searchQuerySchema } from "../validators/search.js";
import { createSearchLimiter } from "../middleware/searchRateLimit.js";

export const searchRoutes = Router();

searchRoutes.get("/", search);
export function createSearchRoutes() {
const router = Router();
const searchLimiter = createSearchLimiter();
router.get("/", searchLimiter, validateQuery(searchQuerySchema), search);
return router;
}
2 changes: 2 additions & 0 deletions apps/api/src/routes/uploadRoutes.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Router } from "express";
import multer from "multer";
import { uploadFile } from "../controllers/uploadController.js";
import { authMiddleware } from "../middleware/auth.js";

const upload = multer({ storage: multer.memoryStorage() });

export const uploadRoutes = Router();

uploadRoutes.use(authMiddleware);
uploadRoutes.post("/", upload.single("file"), uploadFile);
146 changes: 146 additions & 0 deletions apps/api/src/tests/search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createApp } from "../app.js";

let server, port;

function startServer() {
const app = createApp();
server = app.listen(0);
return new Promise((resolve, reject) => {
server.once("listening", () => {
port = server.address().port;
resolve();
});
server.once("error", reject);
});
}

function stopServer() {
return new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}

function url(path) {
return `http://127.0.0.1:${port}${path}`;
}

// ─── Test: valid short query succeeds ────────────────────────────────
test("GET /api/search with valid query returns 200", async () => {
await startServer();
try {
const res = await fetch(url("/api/search?q=hello"));
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.success, true);
assert.equal(body.data.query, "hello");
} finally {
await stopServer();
}
});

// ─── Test: empty/missing query defaults to empty string ──────────────
test("GET /api/search without q param returns 200 with empty query", async () => {
await startServer();
try {
const res = await fetch(url("/api/search"));
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.success, true);
assert.equal(body.data.query, "");
} finally {
await stopServer();
}
});

// ─── Test: query exceeding 200 chars is rejected ─────────────────────
test("GET /api/search rejects query longer than 200 characters", async () => {
await startServer();
try {
const longQuery = "a".repeat(201);
const res = await fetch(url(`/api/search?q=${longQuery}`));
assert.equal(res.status, 400);
const body = await res.json();
assert.equal(body.success, false);
assert.ok(body.message.toLowerCase().includes("200"));
} finally {
await stopServer();
}
});

// ─── Test: regex special characters are rejected ─────────────────────
test("GET /api/search rejects query with regex special characters", async () => {
await startServer();
try {
// These regex metacharacters enable ReDoS and are blocked
const dangerousChars = ["*", "+", "{", "}", "(", ")", "[", "]", "|", "^", "$", "\\"];
for (const ch of dangerousChars) {
const encoded = encodeURIComponent(ch);
const res = await fetch(url(`/api/search?q=test${encoded}query`));
assert.equal(res.status, 400, `Expected 400 for character '${ch}'`);
const body = await res.json();
assert.equal(body.success, false);
}
} finally {
await stopServer();
}
});

// ─── Test: allowed special characters are accepted ───────────────────
test("GET /api/search accepts query with allowed punctuation", async () => {
await startServer();
try {
const allowedQueries = [
"hello world",
"test-query",
"user_name",
"email@example.com",
"price:100",
"it's great!",
"what?",
"say \"hi\""
];
for (const q of allowedQueries) {
const encoded = encodeURIComponent(q);
const res = await fetch(url(`/api/search?q=${encoded}`));
assert.equal(res.status, 200, `Expected 200 for query '${q}'`);
const body = await res.json();
assert.equal(body.success, true);
}
} finally {
await stopServer();
}
});

// ─── Test: sanitizeSearchQuery strips regex characters ───────────────
test("sanitizeSearchQuery strips dangerous regex characters", async () => {
const { sanitizeSearchQuery } = await import("../validators/search.js");
assert.equal(sanitizeSearchQuery("test*query"), "testquery");
assert.equal(sanitizeSearchQuery("a+b"), "ab");
assert.equal(sanitizeSearchQuery("^start$"), "start");
// {1} → "1" (braces stripped, inner content remains)
assert.equal(sanitizeSearchQuery("a{1}b"), "a1b");
assert.equal(sanitizeSearchQuery("normal text"), "normal text");
assert.equal(sanitizeSearchQuery(""), "");
assert.equal(sanitizeSearchQuery(null), "");
});

// ─── Test: rate limiting on search endpoint ──────────────────────────
test("search endpoint rate limits after 20 requests", async () => {
await startServer();
try {
// Fire 20 requests rapidly — should all succeed
for (let i = 0; i < 20; i++) {
const res = await fetch(url("/api/search?q=ratetest"));
assert.equal(res.status, 200, `Request ${i + 1} should succeed`);
}
// 21st should be rate limited
const res = await fetch(url("/api/search?q=ratetest"));
assert.equal(res.status, 429, "21st request should be rate limited");
const body = await res.json();
assert.equal(body.success, false);
} finally {
await stopServer();
}
});
32 changes: 32 additions & 0 deletions apps/api/src/validators/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from "zod";

// Regex metacharacters that enable catastrophic backtracking / ReDoS:
// \ * + { } ( ) [ ] | ^ $
// We allow ? and . as they are common in user search queries and safe alone.
const REDOS_PATTERN = /([\\*+{}()\[\]|^$])/g;

// Allowed: alphanumeric, spaces, basic punctuation (including ? and .)
// Uses * instead of + to allow empty strings (query can be omitted)
const SAFE_QUERY_PATTERN = /^[a-zA-Z0-9\s\-_.@:,!?'"]*$/;

export const MAX_QUERY_LENGTH = 200;

export const searchQuerySchema = z.object({
q: z.string()
.max(MAX_QUERY_LENGTH, `Query must be at most ${MAX_QUERY_LENGTH} characters`)
.regex(SAFE_QUERY_PATTERN, "Query contains disallowed characters. Only alphanumeric, spaces, and basic punctuation are allowed")
.optional()
.default("")
});

/**
* Sanitize a search query by removing regex-dangerous characters.
* Used as a defense-in-depth layer after schema validation.
*/
export function sanitizeSearchQuery(query) {
if (!query) return "";
return query
.replace(REDOS_PATTERN, "")
.trim()
.slice(0, MAX_QUERY_LENGTH);
}
8 changes: 8 additions & 0 deletions apps/api/src/validators/user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from "zod";

export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(["client", "freelancer"]).default("client"),
bio: z.string().max(500).optional()
});
Loading