danielfsousa/express-rest-boilerplate
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.
- ROUTES
- 15
- TABLES
- 3
- FILES READ
- 23/23
- RULES RUN
- 12/12
13 findings
1 critical5 medium7 low
POST /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-baselineand commit the ledger. It stays visible and CI fails only on new findings.auth-before-dataPOST /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-guardedPOST /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-guardedPOST /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-guardedPOST /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-guardedPOST /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-guardedPOST /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-publicPOST /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-publicPOST /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-publicPOST /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-publicpasswordresettokens 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-baselineand commit the ledger. It stays visible and CI fails only on new findings.no-orphan-datastorerefreshtokens 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-baselineand commit the ledger. It stays visible and CI fails only on new findings.no-orphan-datastoreGET /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 typesrc/api/models/refreshToken.model.js— model "RefreshToken": field "userEmail" has an unreadable typesrc/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
[](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.