Skip to content

No Bounds on Result Size or Request Volume (Missing Pagination, Limits and Rate Limiting) #21

Description

@BL4CK570RM

Severity

  • Severity: Low–Medium
  • CVSS v3.1 score: 5.3 (indicative)
  • CVSS v3.1 vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
  • CWE: CWE-770 — Allocation of Resources Without Limits or Throttling

CVSS assumptions and stated limitation: A:L is scored because unbounded response size and unthrottled
unauthenticated writes demonstrably consume disproportionate server/bandwidth resources per request. No denial
of service was demonstrated in this audit
— no timeout, crash, or service outage was produced — so A:H is
deliberately not claimed. If a service-availability loss were later demonstrated, the vector would become
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5. Integrity impact is scored as none here because the
ability to write anonymously is reported separately under the missing-authorization finding.

Summary

The read path issues SELECT * FROM tutorials with no LIMIT and no pagination parameters, so a single
request returns every row in the table. With 20,008 rows in the test table, one unauthenticated request
returned a 1.5 MB response in 33 ms, and a filtered variant returned 1.54 MB in 29 ms — meaning an attacker can
make the application generate large payloads cheaply and repeatedly.

There is no rate limiting and no authentication anywhere in the application, so the same caller can also grow
the table without bound: 50 anonymous POST requests increased the row count from 20,008 to 20,058 with no
quota, credential or throttle.

One part of the request-side picture is bounded and is recorded accurately: a 300 KB request body was rejected
with HTTP 413 by Express's default 100 kB limit. The gap is therefore in result size, write volume and
request rate, not in single-request body size.

Affected Component

Item Value
Repository bezkoder/nodejs-express-mysql
Affected files app/models/tutorial.model.js getAll() (no LIMIT/pagination in the query), app/routes/tutorial.routes.js (no throttling middleware), server.js (no rate-limit middleware)
Affected endpoints GET /api/tutorials, GET /api/tutorials?title=, POST /api/tutorials, DELETE /api/tutorials
Affected commit 68d3959

Technical Description

// app/models/tutorial.model.js — getAll()
let query = "SELECT * FROM tutorials";
if (title) query += ` WHERE title LIKE '%${title}%'`;
sql.query(query, (err, res) => …);     // no LIMIT / OFFSET, no row cap, no streaming

There is no limit/offset query parameter, no maximum result size, no per-account quota, and no rate-limit
middleware in server.js or the router. Because no authentication exists, all four endpoints above are
reachable anonymously, so both the consumption side (large reads) and the growth side (writes) are open to any
caller.

Data flow:

GET /api/tutorials (unauthenticated)
  → findAll (tutorial.controller.js:31)
  → Tutorial.getAll (no LIMIT)
  → SELECT * FROM tutorials
  → every row serialized into one JSON response (1,538,419 bytes observed)

Attack Preconditions

  • Authentication required: none — reads and writes are anonymous.
  • Network access: HTTP access to the API port.
  • Privileges required: none.
  • Special configuration required: none; the absence of pagination is in the source.
  • Works against a default installation: yes; the response size scales with how many rows exist (the table
    must first be filled — achievable anonymously, as shown below, or simply by normal application use).
  • Assumptions used in the reproduction: isolated local MariaDB; the table was seeded to 20,000 rows of
    harmless generated text to measure response size.

Step-by-Step Reproduction / PoC

PoC 1 — Unbounded read (least destructive version: one request, no writes)

# with 20,008 rows present
curl -s -o /tmp/out.json -w 'status=%{http_code} bytes=%{size_download} time=%{time_total}s\n' \
  http://localhost:8080/api/tutorials

curl -s -o /tmp/out2.json -w 'status=%{http_code} bytes=%{size_download} time=%{time_total}s\n' \
  'http://localhost:8080/api/tutorials?title=bulk'

PoC 2 — Unauthenticated, unthrottled writes (small volume, bounded on purpose)

for i in $(seq 1 50); do
  curl -s -o /dev/null -w '%{http_code} ' -X POST http://localhost:8080/api/tutorials \
    -H 'Content-Type: application/json' \
    -d "{\"title\":\"quota-$i\",\"description\":\"anonymous write\",\"published\":false}"
done

PoC 3 — Control: request body size is bounded (not a finding)

# 300 KB JSON body
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/api/tutorials \
  -H 'Content-Type: application/json' --data-binary @/tmp/300kb.json

Observed Result

PoC 1 (/tmp/opencode/audit-lab-FINAL.log, section 10):

########## 10. UNBOUNDED RESULT SET (no pagination, no LIMIT) ##########
  inserted 20000 rows
  rows=20008  response=1538419 bytes  time=33 ms   (single request, no ?limit)
  ?title=bulk -> rows returned in 29 ms, 1537840 bytes

PoC 2:

-- 10b. unauthenticated writes -> unbounded storage growth (no auth, no quota)
  50 anonymous POSTs: rows 20008 -> 20058  (no credentials, no rate limit, no quota)

PoC 3 (control):

-- 7d. request body size limit (express default 100kb)
POST   /api/tutorials   -> HTTP 413

No service interruption, timeout or crash was produced by these tests; the demonstrated condition is
resource exhaustion risk / unbounded consumption, not a confirmed denial of service.

Expected Secure Result

  • Every list endpoint paginates with a server-enforced maximum page size (for example default 20, cap 100).
  • Writes require authentication and are subject to per-account quotas and rate limits.
  • Repeated requests above a threshold are throttled (429 Too Many Requests).
  • Large or expensive queries are bounded (hard LIMIT, statement timeout) and expensive endpoints are
    monitored.

Security Impact

Direct impact

An unauthenticated caller can force the application to serialize and transmit arbitrarily large responses (1.5 MB
per request observed at 20k rows, scaling linearly with table size) and can grow storage without limit via
anonymous inserts, increasing disk, CPU, memory and bandwidth consumption per request. Repeated requests amplify
that cost on both the application and the network path.

Amplified impact

  • Root cause of this finding: absent pagination, result caps, quotas and rate limiting (this file).
  • Interaction with other findings (not causes): the missing-authorization finding is what makes writes
    anonymous, and the SQL-injection finding's table reads are unaffected by pagination. Fixing this finding
    bounds resource use but does not remove anonymous access; fixing authorization does not bound result size.
  • Not claimed: denial of service, service outage, or crash — none were demonstrated.

Evidence

  • Source: app/models/tutorial.model.js getAll() (no LIMIT), app/routes/tutorial.routes.js,
    server.js (no rate-limit middleware) at commit 68d3959.
  • Measured response sizes and timings: /tmp/opencode/audit-lab-FINAL.log, section 10 (1,538,419 bytes /
    33 ms; 1,537,840 bytes / 29 ms).
  • Anonymous write growth: section 10b (20008 → 20058 rows over 50 requests).
  • Body-size control: section 7d (HTTP 413).
  • Harness: /tmp/opencode/audit-lab.sh.
  • Post-fix status: still open (outside the scope of the applied code patch).

Root Cause

The query layer returns complete result sets with no upper bound, and no middleware enforces request rate,
request count or write quota, so per-request resource cost and total resource growth are both unconstrained and
reachable without credentials.

Remediation

// app/controllers/tutorial.controller.js — bounded pagination
exports.findAll = (req, res) => {
  const title = req.query.title;
  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
  const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);

  Tutorial.getAll(title, limit, offset, (err, data) => {
    if (err) {
      console.error(err);
      return res.status(500).send({ message: "Some error occurred while retrieving tutorials." });
    }
    res.send(data);
  });
};
// app/models/tutorial.model.js — bound values, bounded result
let query = "SELECT * FROM tutorials";
const params = [];
if (title) { query += " WHERE title LIKE ?"; params.push("%" + title + "%"); }
query += " LIMIT ? OFFSET ?";
params.push(limit, offset);
sql.query(query, params, callback);
// server.js — throttle write routes
const rateLimit = require("express-rate-limit");
app.use("/api/tutorials", rateLimit({
  windowMs: 60 * 1000,
  limit: 60,
  standardHeaders: "draft-7",
  legacyHeaders: false
}));

Pair with authentication and a per-account creation quota so storage growth is capped per identity, and return
count/total metadata so clients can page without fetching everything.

Defense in Depth

  • Statement timeouts / connection-pool limits to bound expensive queries.
  • Cache or materialize GET /api/tutorials/published-style reads.
  • Monitor response-size and query-duration percentiles; alert on outliers.
  • Cap DELETE /api/tutorials behind an administrative role so bulk operations cannot be triggered anonymously.
  • Keep the existing request-body limit (100 kB, verified as HTTP 413) — it is doing its job.

Affected Versions / Git History

  • getAll() has existed without a LIMIT since app/models/tutorial.model.js was created in commit
    be702f0 (2021-11-10); no rate-limiting or pagination middleware has ever been added to server.js, which
    was last changed by that same commit.
  • Present in current master, commit 68d3959 (2024-02-04). No release tags exist, so the affected version is
    described by commit.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions