Skip to content

fix(backend): add Zod validation, helmet, and rate limiting across all routes#47

Open
stephanieoghenemega-eng wants to merge 20 commits into
Secureflow-protocol:mainfrom
stephanieoghenemega-eng:fix/backend-validation-ratelimit-helmet
Open

fix(backend): add Zod validation, helmet, and rate limiting across all routes#47
stephanieoghenemega-eng wants to merge 20 commits into
Secureflow-protocol:mainfrom
stephanieoghenemega-eng:fix/backend-validation-ratelimit-helmet

Conversation

@stephanieoghenemega-eng

Copy link
Copy Markdown

Closes the backend hardening issue — unvalidated request bodies, no security headers, no rate limiting, and raw service errors leaking to clients.


Commit-by-commit breakdown

Commit 1 — chore(backend): install helmet, express-rate-limit, zod, vitest, supertest

Added to backend/package.json:

  "dependencies": {
+   "express-rate-limit": "^7.5.0",
+   "helmet": "^8.0.0",
+   "zod": "^3.24.0"
  },
  "devDependencies": {
+   "@types/supertest": "^6.0.2",
+   "supertest": "^7.0.0",
+   "vitest": "^2.1.0"
  },
  "scripts": {
+   "test": "vitest run",
+   "test:watch": "vitest"
  }

Commit 2 — feat(backend): add helmet security-headers middleware

helmet() is now the first middleware applied in index.ts. It sets X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Content-Security-Policy, and a dozen others automatically.

// backend/src/index.ts
+ import helmet from "helmet";

  const app = express();
+ app.use(helmet());
  app.use(cors({ origin: true, credentials: true }));

Commit 3 — feat(backend): add global rate limiter — 100 req / 15 min per IP

New file backend/src/middleware/rateLimiter.ts:

import rateLimit from "express-rate-limit";

export const rateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  limit: 100,
  standardHeaders: "draft-7", // returns RateLimit-* headers
  legacyHeaders: false,
  message: { error: "Too many requests, please try again later" },
});

Wired in index.ts before all routes:

+ import { rateLimiter } from "./middleware/rateLimiter.js";

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

Commit 4 — feat(backend): add validateBody/validateQuery Zod middleware helpers

New file backend/src/middleware/validate.ts:

export function validateBody(schema: ZodSchema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error: "Validation failed",
        details: result.error.errors.map(e => ({
          field: e.path.join(".") || "root",
          message: e.message,
        })),
      });
      return;
    }
    req.body = result.data; // replace with parsed/coerced data
    next();
  };
}

export function validateQuery(schema: ZodSchema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.query);
    if (!result.success) {
      res.status(400).json({
        error: "Validation failed",
        details: result.error.errors.map(e => ({
          field: e.path.join(".") || "root",
          message: e.message,
        })),
      });
      return;
    }
    next();
  };
}

Before this, every route had its own ad-hoc checks like:

// old — inconsistent, no structured error shape
if (!content || typeof content !== "string" || !content.trim()) {
  res.status(400).json({ error: "content is required" });
  return;
}

After, every invalid request returns a consistent structured body:

{
  "error": "Validation failed",
  "details": [
    { "field": "content", "message": "content is required" },
    { "field": "sender_address", "message": "must be a valid Stellar G-address" }
  ]
}

Commit 5 — feat(backend): add shared internalError/serviceUnavailable response helpers

New file backend/src/lib/errors.ts:

export function internalError(res: Response): void {
  res.status(500).json({ error: "An internal error occurred" });
}

export function serviceUnavailable(res: Response, service: string): void {
  res.status(503).json({ error: `${service} is not configured` });
}

Before, Supabase error messages were sent directly to the client:

// old — leaks internal DB error strings
if (error) {
  res.status(500).json({ error: error.message });
}

After:

// new — generic message, nothing internal leaks
if (error) {
  internalError(res);
}

Commit 6 — feat(backend/schemas): add Zod schemas for all notifications routes

New file backend/src/schemas/notifications.ts:

const STELLAR_ADDR = /^G[A-Z0-9]{55}$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

const stellarWallet = z.string().regex(STELLAR_ADDR, "must be a valid Stellar G-address");

export const getNotificationsQuery = z.object({ wallet: stellarWallet });

export const patchNotificationReadQuery = z.object({ wallet: stellarWallet });
export const patchNotificationReadParams = z.object({
  id: z.string().regex(UUID_RE, "Invalid notification id"),
});

export const postNotificationBody = z.object({
  wallet_address: stellarWallet,
  type:    z.string().min(1).max(50),
  title:   z.string().min(1).max(200),
  message: z.string().min(1).max(1000),
  action_url: z.string().url().optional().nullable(),
  data:    z.record(z.unknown()).optional(),
});

The old code only checked wallet.startsWith("G") — a 2-character string like "GA" would pass. The new regex enforces the full 56-character Stellar G-address format.


Commit 7 — feat(backend/notifications): apply Zod validation to GET /notifications

// backend/src/routes/notifications.ts

- notificationsRouter.get("/", async (req, res) => {
-   const wallet = String(req.query.wallet ?? "").trim();
-   if (!wallet || !wallet.startsWith("G")) {
-     res.status(400).json({ error: "wallet query must be a Stellar G-address" });
-     return;
-   }
+ notificationsRouter.get("/", validateQuery(getNotificationsQuery), async (req, res) => {
+   // wallet already validated and available via req.query
    const wallet = String(req.query.wallet ?? "").trim();

Also applied validateQuery/validateBody + internalError/serviceUnavailable to PATCH /:id/read and POST / in the same pass, covering all three notification endpoints.


Commit 8 — feat(backend/schemas): add Zod schemas for all messages routes

New file backend/src/schemas/messages.ts — one schema per endpoint:

export const postMessageBody = z.object({
  sender_address:    stellarWallet,
  recipient_address: stellarWallet,
  content: z.string().min(1, "content is required").max(4000),
}).refine(d => d.sender_address !== d.recipient_address, {
  message: "Cannot message yourself",
  path: ["recipient_address"],
});

export const getConversationQuery = z.object({
  a:     stellarWallet,
  b:     stellarWallet,
  since: z.string().datetime({ offset: true }).optional(),
});

export const getInboxQuery          = z.object({ wallet: stellarWallet });
export const getUnreadCountQuery    = z.object({ wallet: stellarWallet });

export const patchConversationReadQuery = z.object({
  a: stellarWallet, b: stellarWallet, wallet: stellarWallet,
});

export const patchMessageReadParams = z.object({
  id: z.string().regex(UUID_RE, "Invalid message id"),
});
export const patchMessageReadQuery  = z.object({ wallet: stellarWallet });

Note: the since param now rejects arbitrary strings — it must be a valid ISO 8601 datetime, preventing garbage values from being forwarded to Supabase.


Commit 9 — feat(backend/messages): replace manual checks with Zod validation on POST /

- messagesRouter.post("/", async (req, res) => {
-   const { sender_address, recipient_address, content } = req.body ?? {};
-   if (!sender_address || !STELLAR_ADDR.test(String(sender_address)) || ...) {
-     res.status(400).json({ error: "sender_address and recipient_address must be valid ..." });
-     return;
-   }
-   if (!content || typeof content !== "string" || !content.trim()) {
-     res.status(400).json({ error: "content is required" });
-     return;
-   }
-   if (sender_address === recipient_address) {
-     res.status(400).json({ error: "Cannot message yourself" });
-     return;
-   }
+ messagesRouter.post("/", validateBody(postMessageBody), async (req, res) => {
+   // all checks handled by Zod, req.body is already clean
    const { sender_address, recipient_address, content } = req.body;

Also removed the redundant .slice(0, 4000) — Zod's max(4000) already rejects anything longer before it reaches the DB.


Commit 10 — feat(backend/messages): apply Zod validation to GET /conversation

- messagesRouter.get("/conversation", async (req, res) => {
-   const a = String(req.query.a ?? "").trim();
-   const b = String(req.query.b ?? "").trim();
-   if (!STELLAR_ADDR.test(a) || !STELLAR_ADDR.test(b)) {
-     res.status(400).json({ error: "a and b must be valid Stellar G-addresses" });
-     return;
-   }
+ messagesRouter.get("/conversation", validateQuery(getConversationQuery), async (req, res) => {

The since param is also now validated as a datetime before being passed to .gt("created_at", since).


Commit 11 — feat(backend/messages): apply Zod validation to GET /inbox

- messagesRouter.get("/inbox", async (req, res) => {
-   const wallet = String(req.query.wallet ?? "").trim();
-   if (!STELLAR_ADDR.test(wallet)) {
-     res.status(400).json({ error: "wallet must be a valid Stellar G-address" });
-     return;
-   }
-   ...
-   if (error) {
-     res.status(500).json({ error: error.message }); // leaked Supabase error
-   }
+ messagesRouter.get("/inbox", validateQuery(getInboxQuery), async (req, res) => {
+   ...
+   if (error) {
+     internalError(res); // generic, nothing leaked
+   }

Commit 12 — feat(backend/messages): apply Zod validation to GET /unread-count

- messagesRouter.get("/unread-count", async (req, res) => {
-   const wallet = String(req.query.wallet ?? "").trim();
-   if (!STELLAR_ADDR.test(wallet)) { ... }
+ messagesRouter.get("/unread-count", validateQuery(getUnreadCountQuery), async (req, res) => {

Commit 13 — feat(backend/messages): apply Zod validation to PATCH /conversation/read

- messagesRouter.patch("/conversation/read", async (req, res) => {
-   if (!STELLAR_ADDR.test(a) || !STELLAR_ADDR.test(b) || !STELLAR_ADDR.test(wallet)) {
-     res.status(400).json({ error: "a, b, and wallet must be valid Stellar G-addresses" });
-     return;
-   }
+ messagesRouter.patch("/conversation/read", validateQuery(patchConversationReadQuery), async (req, res) => {

Commit 14 — feat(backend/messages): apply Zod validation to PATCH /:id/read; sanitize Supabase errors

- messagesRouter.patch("/:id/read", async (req, res) => {
-   const id = req.params.id;
-   if (!UUID_RE.test(id)) {
-     res.status(400).json({ error: "Invalid message id" });
-     return;
-   }
-   const wallet = String(req.query.wallet ?? "").trim();
-   if (!STELLAR_ADDR.test(wallet)) {
-     res.status(400).json({ error: "wallet must be a valid Stellar G-address" });
-     return;
-   }
+ messagesRouter.patch("/:id/read", validateQuery(patchMessageReadQuery), async (req, res) => {
+   const idResult = patchMessageReadParams.safeParse({ id: req.params.id });
+   if (!idResult.success) {
+     res.status(400).json({ error: "Validation failed", details: [...] });
+     return;
+   }

At this point all 6 message endpoints are fully covered and no raw STELLAR_ADDR/UUID_RE regex or raw error.message leaks remain in messages.ts.


Commit 15 — feat(backend/schemas): add Zod schemas for all AI route bodies

New file backend/src/schemas/ai.ts:

export const postMilestonesBody = z.object({
  projectTitle:       z.string().max(200).default(""),
  projectDescription: z.string().max(5000).default(""),
  totalBudget:        z.string().max(50).default(""),
  durationDays:       z.string().max(20).default(""),
  userPrompt:         z.string().min(1, "userPrompt is required").max(2000),
  milestoneIndex:     z.number().int().nullable().default(null),
});

export const postCoverLetterBody = z.object({
  jobTitle:             z.string().max(200).default(""),
  jobDescription:       z.string().min(1, "jobDescription is required").max(5000),
  proposedTimelineDays: z.string().max(20).optional(),
  tone:                 z.string().max(50).optional(),
  userDraft:            z.string().max(3000).optional(),
});

export const postRewriteBody = z.object({
  text: z.string().min(1, "text is required").max(5000),
});

Schemas also enforce maximum lengths on all string fields — previously the AI routes passed unbounded user input directly to the Groq API.


Commit 16 — feat(backend/ai): apply Zod validation to POST /milestones

- aiRouter.post("/milestones", async (req, res) => {
-   const { projectTitle = "", ..., userPrompt = "" } = req.body ?? {};
-   if (!String(userPrompt).trim()) {
-     res.status(400).json({ error: "userPrompt is required" });
-     return;
-   }
-   const suggestions = await generateMilestoneSuggestions({
-     projectTitle: String(projectTitle),  // manual casting everywhere
-     ...
-   });
+ aiRouter.post("/milestones", validateBody(postMilestonesBody), async (req, res) => {
+   const { projectTitle, projectDescription, totalBudget, durationDays, userPrompt, milestoneIndex } = req.body;
+   // all fields already typed and coerced by Zod
+   const suggestions = await generateMilestoneSuggestions({ projectTitle, ... });

Commit 17 — feat(backend/ai): apply Zod validation to POST /cover-letter

- aiRouter.post("/cover-letter", async (req, res) => {
-   const { jobTitle = "", jobDescription = "", ... } = req.body ?? {};
-   if (!String(jobDescription).trim()) {
-     res.status(400).json({ error: "jobDescription is required" });
-     return;
-   }
-   const coverLetter = await generateCoverLetter({
-     jobTitle: String(jobTitle),
-     proposedTimelineDays: proposedTimelineDays ? String(proposedTimelineDays) : undefined,
-     ...
-   });
+ aiRouter.post("/cover-letter", validateBody(postCoverLetterBody), async (req, res) => {
+   const { jobTitle, jobDescription, proposedTimelineDays, tone, userDraft } = req.body;
+   const coverLetter = await generateCoverLetter({ jobTitle, jobDescription, proposedTimelineDays, tone, userDraft });

Commit 18 — feat(backend/ai): apply Zod validation to POST /rewrite; sanitize Groq errors from 500 responses

- aiRouter.post("/rewrite", async (req, res) => {
-   const { text = "" } = req.body ?? {};
-   if (!String(text).trim()) {
-     res.status(400).json({ error: "text is required" });
-     return;
-   }
  } catch (e) {
    const msg = e instanceof Error ? e.message : "AI rewrite failed";
    const status = msg.includes("GROQ_API_KEY") ? 503 : 500;
-   res.status(status).json({ error: msg }); // leaked Groq SDK error messages
+   res.status(status).json({ error: status === 503 ? "AI service not configured" : "AI rewrite failed" });
  }

Same sanitization applied to /milestones and /cover-letter in their respective commits.


Commit 19 — feat(backend): add Zod schemas and validation for gasless and upload routes; sanitize errors

backend/src/schemas/gasless.ts:

export const postGaslessApplyBody = z.object({
  signedTxXdr: z.string()
    .min(48, "signedTxXdr is too short to be valid XDR")
    .max(100_000, "signedTxXdr exceeds maximum allowed length"),
});

backend/src/schemas/upload.ts:

export const uploadMilestoneBody = z.object({
  escrow_id:       z.string().min(1).max(200).default("unknown"),
  milestone_index: z.coerce.number().int().min(0).default(0),
});

Applied in their routes:

// gasless.ts
- gaslessRouter.post("/apply", async (req, res) => {
-   const { signedTxXdr } = req.body ?? {};
-   if (!signedTxXdr || typeof signedTxXdr !== "string") {
-     res.status(400).json({ error: "signedTxXdr is required" });
-     return;
-   }
-   const normalizedXdr = normalizeSignedTxXdr(signedTxXdr);
-   if (normalizedXdr.length < 48) {
-     res.status(400).json({ error: "signedTxXdr is too short to be valid XDR" });
-     return;
-   }
+ gaslessRouter.post("/apply", validateBody(postGaslessApplyBody), async (req, res) => {
+   const { signedTxXdr } = req.body; // already validated
+   const normalizedXdr = normalizeSignedTxXdr(signedTxXdr);

// upload.ts
- const escrowId = String(req.body.escrow_id ?? "unknown");
- const milestoneIndex = String(req.body.milestone_index ?? "0");
+ const bodyResult = uploadMilestoneBody.safeParse(req.body);
+ if (!bodyResult.success) {
+   res.status(400).json({ error: "Validation failed", details: [...] });
+   return;
+ }
+ const escrowId = String(bodyResult.data.escrow_id);
+ const milestoneIndex = String(bodyResult.data.milestone_index);

Commit 20 — test(backend): add vitest validation tests for all route schemas

New file backend/src/__tests__/validation.test.ts — 30 unit tests that validate the Zod schemas directly, covering every route:

// example subset
describe("postMessageBody", () => {
  it("accepts valid payload", () => {
    expect(postMessageBody.safeParse({
      sender_address:    "G" + "A".repeat(55),
      recipient_address: "G" + "B".repeat(55),
      content: "hello",
    }).success).toBe(true);
  });

  it("rejects self-messaging", () => {
    expect(postMessageBody.safeParse({
      sender_address:    "G" + "A".repeat(55),
      recipient_address: "G" + "A".repeat(55),
      content: "hi",
    }).success).toBe(false);
  });

  it("rejects content exceeding 4000 chars", () => {
    expect(postMessageBody.safeParse({
      sender_address:    "G" + "A".repeat(55),
      recipient_address: "G" + "B".repeat(55),
      content: "x".repeat(4001),
    }).success).toBe(false);
  });
});

describe("getNotificationsQuery", () => {
  it("rejects wallet that only starts with G but is too short", () => {
    // old startsWith("G") check would have passed this
    expect(getNotificationsQuery.safeParse({ wallet: "GABC" }).success).toBe(false);
  });
});

Run with:

cd backend && npm test

Acceptance criteria met

Criterion Status
Invalid input → 400 with structured { error, details } body done
Rate limiting active (100 req / 15 min, RateLimit-* headers) done
helmet() applied to all responses done
No raw Supabase or Groq errors in any 500 response done
Tests for validation logic done — 30 tests across all schemas

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant