morphit/apps/indexer/test/api/bodyCap.test.ts

156 lines
5 KiB
TypeScript

/**
* Tests for body-cap middleware.
*
* Coverage:
* - Valid Content-Length within budget passes through
* - Valid Content-Length over budget rejected with 413
* - Malformed Content-Length rejected with 400
*
* cp70-D1 regression: parseInt() silently accepts trailing garbage
* ("999000abc" → 999000), which would let a hostile client smuggle
* a misdeclared Content-Length past the cap. The middleware now
* uses strict /^\d+$/ validation BEFORE parsing.
*/
import { describe, expect, it } from 'vitest';
import { Hono } from 'hono';
import { bodyCap } from '$api/middleware/bodyCap';
function buildApp(maxBytes: number): Hono {
const app = new Hono();
app.use('*', bodyCap(maxBytes));
app.post('/post', (c) => c.json({ ok: true }));
app.get('/get', (c) => c.json({ ok: true }));
return app;
}
describe('bodyCap middleware', () => {
it('accepts a POST with Content-Length within budget', async () => {
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '100' },
body: '{"x":1}'
});
expect(res.status).toBe(200);
});
it('rejects a POST with Content-Length over budget', async () => {
const app = buildApp(1_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '999999' },
body: '{"x":1}'
});
expect(res.status).toBe(413);
});
// ─── cp70-D1 regression: trailing-garbage smuggling ───────────
it('rejects Content-Length with trailing garbage ("999000abc")', async () => {
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '999000abc' },
body: '{"x":1}'
});
expect(res.status).toBe(400);
const body = (await res.json()) as { code?: string };
expect(body.code).toBe('bad_request');
});
it('rejects Content-Length with leading garbage ("abc100")', async () => {
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'content-length': 'abc100' },
body: '{"x":1}'
});
expect(res.status).toBe(400);
});
it('rejects Content-Length with embedded whitespace ("1 23")', async () => {
// parseInt("1 23") = 1 (silently strips trailing chars from
// the first non-digit). Pure /^\d+$/ rejects. Note:
// LEADING-only or TRAILING-only whitespace is normalized away
// by Node's Headers implementation before our middleware
// sees the header, so we can't easily test those values in
// isolation, but embedded whitespace survives normalization.
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '1 23' },
body: '{"x":1}'
});
expect(res.status).toBe(400);
});
it('rejects Content-Length with a hex sentinel ("0xFF")', async () => {
// parseInt("0xFF", 10) = 0 (silently parses just "0"), which
// would have erroneously passed the bodyCap check before the
// fix. Now we require pure digits, so this is correctly
// rejected as malformed.
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '0xFF' },
body: '{"x":1}'
});
expect(res.status).toBe(400);
});
it('rejects Content-Length with a sign ("+100" / "-100")', async () => {
// parseInt accepts "+100" → 100 and "-100" → -100. Our regex
// rejects both, then the negative check would catch -100 even
// if the regex didn't. Defense-in-depth.
const app = buildApp(1_000_000);
const resPlus = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '+100' },
body: '{"x":1}'
});
expect(resPlus.status).toBe(400);
const resMinus = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '-100' },
body: '{"x":1}'
});
expect(resMinus.status).toBe(400);
});
it('rejects Content-Length with scientific notation ("1e3")', async () => {
// parseInt("1e3", 10) = 1 (silently parses just "1"), so a
// hostile client could declare a tiny size and ship a
// 1000-byte body. /^\d+$/ rejects 'e'.
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'content-length': '1e3' },
body: '{"x":1}'
});
expect(res.status).toBe(400);
});
it('accepts a body-bearing method without Content-Length and without Transfer-Encoding (HTTP/1.1 "no body")', async () => {
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST'
});
expect(res.status).toBe(200);
});
it('rejects chunked transfer-encoding on body-bearing methods (411)', async () => {
const app = buildApp(1_000_000);
const res = await app.request('/post', {
method: 'POST',
headers: { 'transfer-encoding': 'chunked' }
});
expect(res.status).toBe(411);
});
it('passes GET through (no body-bearing check)', async () => {
const app = buildApp(1_000);
const res = await app.request('/get', { method: 'GET' });
expect(res.status).toBe(200);
});
});