brocoders/nestjs-boilerplate
No critical or high-risk findings. 7 worth a look.
21 warnings in the paths between 19 routes and 5 tables.
- ROUTES
- 19
- TABLES
- 5
- FILES READ
- 176/176
- RULES RUN
- 12/12
21 findings
7 medium14 low
POST /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 /email/login has no validator or rate limiter attached.
POST /email/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/email/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 /email/login rejects an unexpected body", async () => { const res = await request(app).post("/email/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 /email/register has no validator or rate limiter attached.
POST /email/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/email/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 /email/register rejects an unexpected body", async () => { const res = await request(app).post("/email/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 /email/confirm has no validator or rate limiter attached.
POST /email/confirm 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/email/confirm' \ -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 /email/confirm rejects an unexpected body", async () => { const res = await request(app).post("/email/confirm").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 /email/confirm/new has no validator or rate limiter attached.
POST /email/confirm/new 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/email/confirm/new' \ -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 /email/confirm/new rejects an unexpected body", async () => { const res = await request(app).post("/email/confirm/new").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 /forgot/password has no validator or rate limiter attached.
POST /forgot/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/forgot/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 /forgot/password rejects an unexpected body", async () => { const res = await request(app).post("/forgot/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 /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 /login reaches session 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
session(write) · sensitive:hash- 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 → session
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 /login reaches user 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
user(write) · 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 → user
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 /email/login reaches session without auth, which is expected for an authentication endpoint.
POST /email/login must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.
- Data reached
session(write) · sensitive:hash- 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 /email/login → session
Fix
// 5 attempts per IP per minute on sign-in. app.post("/email/login", rateLimit({ windowMs: 60_000, limit: 5 }), handler);Regression test
test("POST /email/login is rate limited", async () => { for (let i = 0; i < 5; i++) await request(app).post("/email/login").send({ email: "a@b.c", password: "x" }); const res = await request(app).post("/email/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 /email/login reaches user without auth, which is expected for an authentication endpoint.
POST /email/login must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.
- Data reached
user(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 /email/login → user
Fix
// 5 attempts per IP per minute on sign-in. app.post("/email/login", rateLimit({ windowMs: 60_000, limit: 5 }), handler);Regression test
test("POST /email/login is rate limited", async () => { for (let i = 0; i < 5; i++) await request(app).post("/email/login").send({ email: "a@b.c", password: "x" }); const res = await request(app).post("/email/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 /email/register reaches user without auth, which is expected for an authentication endpoint.
POST /email/register must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.
- Data reached
user(write) · 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 /email/register → user
Fix
// 5 attempts per IP per minute on sign-in. app.post("/email/register", rateLimit({ windowMs: 60_000, limit: 5 }), handler);Regression test
test("POST /email/register is rate limited", async () => { for (let i = 0; i < 5; i++) await request(app).post("/email/register").send({ email: "a@b.c", password: "x" }); const res = await request(app).post("/email/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 /email/confirm reaches user without auth, which is expected for an authentication endpoint.
POST /email/confirm must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.
- Data reached
user(write) · 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 /email/confirm → user
Fix
// 5 attempts per IP per minute on sign-in. app.post("/email/confirm", rateLimit({ windowMs: 60_000, limit: 5 }), handler);Regression test
test("POST /email/confirm is rate limited", async () => { for (let i = 0; i < 5; i++) await request(app).post("/email/confirm").send({ email: "a@b.c", password: "x" }); const res = await request(app).post("/email/confirm").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 /email/confirm/new reaches user without auth, which is expected for an authentication endpoint.
POST /email/confirm/new must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.
- Data reached
user(write) · 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 /email/confirm/new → user
Fix
// 5 attempts per IP per minute on sign-in. app.post("/email/confirm/new", rateLimit({ windowMs: 60_000, limit: 5 }), handler);Regression test
test("POST /email/confirm/new is rate limited", async () => { for (let i = 0; i < 5; i++) await request(app).post("/email/confirm/new").send({ email: "a@b.c", password: "x" }); const res = await request(app).post("/email/confirm/new").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 /forgot/password reaches user without auth, which is expected for an authentication endpoint.
POST /forgot/password must be public — it is how people sign in. It is also where credential stuffing and account enumeration arrive.
- Data reached
user(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 /forgot/password → user
Fix
// 5 attempts per IP per minute on sign-in. app.post("/forgot/password", rateLimit({ windowMs: 60_000, limit: 5 }), handler);Regression test
test("POST /forgot/password is rate limited", async () => { for (let i = 0; i < 5; i++) await request(app).post("/forgot/password").send({ email: "a@b.c", password: "x" }); const res = await request(app).post("/forgot/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-publicPOST /reset/password reaches session 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
session(write) · sensitive:hash- 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 → session
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-publicPOST /reset/password reaches user 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
user(write) · 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 → user
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-publicrole is not connected to anything.
role 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-datastorestatus is not connected to anything.
status 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 /:path has no validator or rate limiter attached.
GET /:path 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("/:path", 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-guardedGET / has no validator or rate limiter attached.
GET / 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("/", 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
Free account, no card. The repository opens as an editable graph.
Add this check to the README
[](https://wyro.in/scan/brocoders/nestjs-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.