danielfsousa/express-rest-boilerplate

1 error, 12 warningsmain

1 critical finding to fix first.

1 error and 12 warnings in the paths between 15 routes and 3 tables.

Every file was read, but 3 places in them could not be parsed — listed at the end of this report.

ROUTE FINDINGS6 of 15 · 12/12 rules
ROUTES
15
TABLES
3
FILES READ
23/23
RULES RUN
12/12

13 findings

1 critical5 medium7 low

  • Criticalhigh confidencePOST /send-password-reset can reach users without authenticating.

    Anyone, signed in or not, can read users (email, password) — personal or secret fields.

    Data reached
    users (read) · sensitive: email, password
    Missing
    Authentication before the handler reaches data, and a query scoped to the caller.
    Confidence
    No middleware, guard or wrapper is applied to this route anywhere in src/api/routes/v1/auth.route.js.
    Path
    POST /send-password-reset → users

    Prove it

    # No cookie, no Authorization header.
    curl -i -X POST 'https://YOUR_API/send-password-reset' \
      -H 'content-type: application/json' -d '{}'
    # Expect 401. A 2xx means anyone on the internet can do this.

    Fix

    // Put the guard IN FRONT of the handler, so it runs first.
    app.post("/send-password-reset", requireAuth, async (req, res) => {
      // ...scope every query to req.user.id, not just the :id in the URL
    });
    
    function requireAuth(req, res, next) {
      const user = verifySession(req.headers.authorization);
      if (!user) return res.status(401).json({ error: "unauthorized" });
      req.user = user;
      next();
    }

    Regression test

    import request from "supertest";
    
    test("POST /send-password-reset rejects a signed-out caller", async () => {
      const res = await request(app).post("/send-password-reset");
      expect(res.status).toBe(401);
    });

    Intended? This cannot be declared intentional: public sensitive data is not a design choice the check will bless. Accept it as known debt: run wyro-check --update-baseline and commit the ledger. It stays visible and CI fails only on new findings.

    auth-before-data

  • Mediumlow confidencePOST /register has no validator or rate limiter attached.

    POST /register accepts any body at any rate. Malformed input reaches your handler, and nothing stops a script calling it in a loop.

    Missing
    Schema validation of the request body, and a rate limit.
    Confidence
    Inline validation (zod.parse, pydantic models) and platform rate limits are not always visible to the parser. Check before acting.

    Prove it

    curl -i -X POST 'https://YOUR_API/register' \
      -H 'content-type: application/json' -d '{"unexpected": {"nested": [1,2,3]}}'
    # Expect 400. A 2xx or 500 means the body is not validated.

    Fix

    const Body = z.object({ name: z.string().max(200) }).strict();
    
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return res.status(400).json(parsed.error.flatten());

    Regression test

    test("POST /register rejects an unexpected body", async () => {
      const res = await request(app).post("/register").send({ unexpected: true });
      expect(res.status).toBe(400);
    });

    Intended? If validation and rate limiting happen upstream (an API gateway, Vercel Firewall), turn the rule off for the repo: { "policy": { "rules": { "public-entry-guarded": "off" } } } in wyro.json.

    public-entry-guarded

  • Mediumlow confidencePOST /login has no validator or rate limiter attached.

    POST /login accepts any body at any rate. Malformed input reaches your handler, and nothing stops a script calling it in a loop.

    Missing
    Schema validation of the request body, and a rate limit.
    Confidence
    Inline validation (zod.parse, pydantic models) and platform rate limits are not always visible to the parser. Check before acting.

    Prove it

    curl -i -X POST 'https://YOUR_API/login' \
      -H 'content-type: application/json' -d '{"unexpected": {"nested": [1,2,3]}}'
    # Expect 400. A 2xx or 500 means the body is not validated.

    Fix

    const Body = z.object({ name: z.string().max(200) }).strict();
    
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return res.status(400).json(parsed.error.flatten());

    Regression test

    test("POST /login rejects an unexpected body", async () => {
      const res = await request(app).post("/login").send({ unexpected: true });
      expect(res.status).toBe(400);
    });

    Intended? If validation and rate limiting happen upstream (an API gateway, Vercel Firewall), turn the rule off for the repo: { "policy": { "rules": { "public-entry-guarded": "off" } } } in wyro.json.

    public-entry-guarded

  • Mediumlow confidencePOST /refresh-token has no validator or rate limiter attached.

    POST /refresh-token accepts any body at any rate. Malformed input reaches your handler, and nothing stops a script calling it in a loop.

    Missing
    Schema validation of the request body, and a rate limit.
    Confidence
    Inline validation (zod.parse, pydantic models) and platform rate limits are not always visible to the parser. Check before acting.

    Prove it

    curl -i -X POST 'https://YOUR_API/refresh-token' \
      -H 'content-type: application/json' -d '{"unexpected": {"nested": [1,2,3]}}'
    # Expect 400. A 2xx or 500 means the body is not validated.

    Fix

    const Body = z.object({ name: z.string().max(200) }).strict();
    
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return res.status(400).json(parsed.error.flatten());

    Regression test

    test("POST /refresh-token rejects an unexpected body", async () => {
      const res = await request(app).post("/refresh-token").send({ unexpected: true });
      expect(res.status).toBe(400);
    });

    Intended? If validation and rate limiting happen upstream (an API gateway, Vercel Firewall), turn the rule off for the repo: { "policy": { "rules": { "public-entry-guarded": "off" } } } in wyro.json.

    public-entry-guarded

  • Mediumlow confidencePOST /send-password-reset has no validator or rate limiter attached.

    POST /send-password-reset accepts any body at any rate. Malformed input reaches your handler, and nothing stops a script calling it in a loop.

    Missing
    Schema validation of the request body, and a rate limit.
    Confidence
    Inline validation (zod.parse, pydantic models) and platform rate limits are not always visible to the parser. Check before acting.

    Prove it

    curl -i -X POST 'https://YOUR_API/send-password-reset' \
      -H 'content-type: application/json' -d '{"unexpected": {"nested": [1,2,3]}}'
    # Expect 400. A 2xx or 500 means the body is not validated.

    Fix

    const Body = z.object({ name: z.string().max(200) }).strict();
    
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return res.status(400).json(parsed.error.flatten());

    Regression test

    test("POST /send-password-reset rejects an unexpected body", async () => {
      const res = await request(app).post("/send-password-reset").send({ unexpected: true });
      expect(res.status).toBe(400);
    });

    Intended? If validation and rate limiting happen upstream (an API gateway, Vercel Firewall), turn the rule off for the repo: { "policy": { "rules": { "public-entry-guarded": "off" } } } in wyro.json.

    public-entry-guarded

  • Mediumlow confidencePOST /reset-password has no validator or rate limiter attached.

    POST /reset-password accepts any body at any rate. Malformed input reaches your handler, and nothing stops a script calling it in a loop.

    Missing
    Schema validation of the request body, and a rate limit.
    Confidence
    Inline validation (zod.parse, pydantic models) and platform rate limits are not always visible to the parser. Check before acting.

    Prove it

    curl -i -X POST 'https://YOUR_API/reset-password' \
      -H 'content-type: application/json' -d '{"unexpected": {"nested": [1,2,3]}}'
    # Expect 400. A 2xx or 500 means the body is not validated.

    Fix

    const Body = z.object({ name: z.string().max(200) }).strict();
    
    const parsed = Body.safeParse(req.body);
    if (!parsed.success) return res.status(400).json(parsed.error.flatten());

    Regression test

    test("POST /reset-password rejects an unexpected body", async () => {
      const res = await request(app).post("/reset-password").send({ unexpected: true });
      expect(res.status).toBe(400);
    });

    Intended? If validation and rate limiting happen upstream (an API gateway, Vercel Firewall), turn the rule off for the repo: { "policy": { "rules": { "public-entry-guarded": "off" } } } in wyro.json.

    public-entry-guarded

  • Lowhigh confidencePOST /register reaches users without auth, which is expected for an authentication endpoint.

    POST /register must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.

    Data reached
    users (read) · sensitive: email, password
    Missing
    A tight rate limit and identical responses for unknown users and wrong passwords.
    Confidence
    Identified as an authentication endpoint by its path, touching identity tables only.
    Path
    POST /register → users

    Fix

    // 5 attempts per IP per minute on sign-in.
    app.post("/register", rateLimit({ windowMs: 60_000, limit: 5 }), handler);

    Regression test

    test("POST /register is rate limited", async () => {
      for (let i = 0; i < 5; i++) await request(app).post("/register").send({ email: "a@b.c", password: "x" });
      const res = await request(app).post("/register").send({ email: "a@b.c", password: "x" });
      expect(res.status).toBe(429);
    });

    Intended? Informational: this is reported so the front door stays in view, not because it is wrong.

    auth-entry-public

  • Lowhigh confidencePOST /login reaches users without auth, which is expected for an authentication endpoint.

    POST /login must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.

    Data reached
    users (read) · sensitive: email, password
    Missing
    A tight rate limit and identical responses for unknown users and wrong passwords.
    Confidence
    Identified as an authentication endpoint by its path, touching identity tables only.
    Path
    POST /login → users

    Fix

    // 5 attempts per IP per minute on sign-in.
    app.post("/login", rateLimit({ windowMs: 60_000, limit: 5 }), handler);

    Regression test

    test("POST /login is rate limited", async () => {
      for (let i = 0; i < 5; i++) await request(app).post("/login").send({ email: "a@b.c", password: "x" });
      const res = await request(app).post("/login").send({ email: "a@b.c", password: "x" });
      expect(res.status).toBe(429);
    });

    Intended? Informational: this is reported so the front door stays in view, not because it is wrong.

    auth-entry-public

  • Lowhigh confidencePOST /refresh-token reaches users without auth, which is expected for an authentication endpoint.

    POST /refresh-token must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.

    Data reached
    users (read) · sensitive: email, password
    Missing
    A tight rate limit and identical responses for unknown users and wrong passwords.
    Confidence
    Identified as an authentication endpoint by its path, touching identity tables only.
    Path
    POST /refresh-token → users

    Fix

    // 5 attempts per IP per minute on sign-in.
    app.post("/refresh-token", rateLimit({ windowMs: 60_000, limit: 5 }), handler);

    Regression test

    test("POST /refresh-token is rate limited", async () => {
      for (let i = 0; i < 5; i++) await request(app).post("/refresh-token").send({ email: "a@b.c", password: "x" });
      const res = await request(app).post("/refresh-token").send({ email: "a@b.c", password: "x" });
      expect(res.status).toBe(429);
    });

    Intended? Informational: this is reported so the front door stays in view, not because it is wrong.

    auth-entry-public

  • Lowhigh confidencePOST /reset-password reaches users without auth, which is expected for an authentication endpoint.

    POST /reset-password must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.

    Data reached
    users (read) · sensitive: email, password
    Missing
    A tight rate limit and identical responses for unknown users and wrong passwords.
    Confidence
    Identified as an authentication endpoint by its path, touching identity tables only.
    Path
    POST /reset-password → users

    Fix

    // 5 attempts per IP per minute on sign-in.
    app.post("/reset-password", rateLimit({ windowMs: 60_000, limit: 5 }), handler);

    Regression test

    test("POST /reset-password is rate limited", async () => {
      for (let i = 0; i < 5; i++) await request(app).post("/reset-password").send({ email: "a@b.c", password: "x" });
      const res = await request(app).post("/reset-password").send({ email: "a@b.c", password: "x" });
      expect(res.status).toBe(429);
    });

    Intended? Informational: this is reported so the front door stays in view, not because it is wrong.

    auth-entry-public

  • Lowhigh confidencepasswordresettokens is not connected to anything.

    passwordresettokens is connected to nothing: dead weight, or a route that was meant to use it and does not.

    Missing
    A route that uses it, or its removal.
    Confidence
    Nothing is wired to it in the design.

    Intended? If it is used by a job or another service, accept it as known debt: run wyro-check --update-baseline and commit the ledger. It stays visible and CI fails only on new findings.

    no-orphan-datastore

  • Lowhigh confidencerefreshtokens is not connected to anything.

    refreshtokens is connected to nothing: dead weight, or a route that was meant to use it and does not.

    Missing
    A route that uses it, or its removal.
    Confidence
    Nothing is wired to it in the design.

    Intended? If it is used by a job or another service, accept it as known debt: run wyro-check --update-baseline and commit the ledger. It stays visible and CI fails only on new findings.

    no-orphan-datastore

  • Lowlow confidenceGET /status has no validator or rate limiter attached.

    GET /status can be called at any rate. Cheap to fix, rarely urgent on a read.

    Missing
    A rate limit.
    Confidence
    Inline validation (zod.parse, pydantic models) and platform rate limits are not always visible to the parser. Check before acting.

    Fix

    // 60 requests per IP per minute.
    app.get("/status", rateLimit({ windowMs: 60_000, limit: 60 }), handler);

    Intended? If validation and rate limiting happen upstream (an API gateway, Vercel Firewall), turn the rule off for the repo: { "policy": { "rules": { "public-entry-guarded": "off" } } } in wyro.json.

    public-entry-guarded

3 places could not be parsed, so any route or table declared there is missing from this report:

  • src/api/models/passwordResetToken.model.js — model "PasswordResetToken": field "userEmail" has an unreadable type
  • src/api/models/refreshToken.model.js — model "RefreshToken": field "userEmail" has an unreadable type
  • src/api/models/user.model.js — model "User": field "services" has an unreadable type

Free account, no card. The repository opens as an editable graph.

Add this check to the README

wyro architecture badge
[![wyro architecture](https://wyro.in/api/badge/danielfsousa/express-rest-boilerplate)](https://wyro.in/scan/danielfsousa/express-rest-boilerplate)

It updates itself whenever the repository changes and links back to this report.

What this is

Wyro reads the repository’s routes and data models and checks the paths between them: whether a route can reach a table without passing a guard, whether a datastore holding personal data is exposed to a public read, whether an endpoint that issues credentials requires the credentials it issues.

It is rule-based, not a model. The same commit produces the same result every time, and it does not guess at business rules it cannot see. A rule with nothing to look at is reported as not having run — never as a pass.

This check runs on public source through GitHub’s own API and needs no account. A free account adds the editable architecture canvas, private repositories on paid plans, and a CI gate. No card required.

Maintain this repository? You can have this report removed, and a real vulnerability is disclosed to you privately before it is published. How public reports are handled.