Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ program
.option('--cors-proxy-max-bytes <n>', 'CORS proxy upstream response size cap (default 50MB)', parseInt)
.option('--cors-proxy-timeout-ms <ms>', 'CORS proxy upstream request timeout (default 30s)', parseInt)
.option('--cors-proxy-max-redirects <n>', 'CORS proxy max redirect hops, each re-validated (default 5)', parseInt)
.option('--body-limit <size>', 'Maximum request body size, e.g. 100MB or 1GB (default 10MB). Raise to accept larger `git push`; lower for tighter memory-DoS protection.')
.option('--nostr', 'Enable Nostr relay')
.option('--no-nostr', 'Disable Nostr relay')
.option('--nostr-path <path>', 'Nostr relay WebSocket path (default: /relay)')
Expand Down
11 changes: 9 additions & 2 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export const defaults = {
port: 4443,
host: '0.0.0.0',
root: './data',
// Maximum request body size in bytes (or a size string when supplied
// via CLI / config file, e.g. "100MB"). Default 10 MiB matches the
// previous hard-coded Fastify limit. Operators hosting personal pods
// may want to raise this so that large `git push` of an established
// app repo doesn't 413 — see #474.
bodyLimit: 10 * 1024 * 1024,

// SSL
sslKey: null,
Expand Down Expand Up @@ -167,6 +173,7 @@ const envMap = {
JSS_CORS_PROXY_MAX_BYTES: 'corsProxyMaxBytes',
JSS_CORS_PROXY_TIMEOUT_MS: 'corsProxyTimeoutMs',
JSS_CORS_PROXY_MAX_REDIRECTS: 'corsProxyMaxRedirects',
JSS_BODY_LIMIT: 'bodyLimit',
JSS_NOSTR: 'nostr',
JSS_NOSTR_PATH: 'nostrPath',
JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents',
Expand Down Expand Up @@ -278,8 +285,8 @@ function parseEnvValue(value, key) {
return parseInt(value, 10);
}

// Size values (quota)
if (key === 'defaultQuota') {
// Size values (quota, body limit)
if (key === 'defaultQuota' || key === 'bodyLimit') {
return parseSize(value);
}

Expand Down
16 changes: 13 additions & 3 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { AccessMode } from './wac/parser.js';
import { registerNostrRelay } from './nostr/relay.js';
import { createPayHandler, isPayRequest } from './handlers/pay.js';
import { activityPubPlugin, getActorHandler } from './ap/index.js';
import { defaults } from './config.js';
import { defaults, parseSize } from './config.js';
import { remoteStoragePlugin } from './remotestorage.js';
import { dbPlugin } from './db/index.js';
import { mcpPlugin } from './mcp/index.js';
Expand Down Expand Up @@ -183,14 +183,24 @@ export function createServer(options = {}) {

// Fastify options
const loggerEnabled = options.logger ?? true;
// Resolve bodyLimit from options. Numbers (programmatic, or env values
// already coerced by parseEnvValue) pass through unchanged; strings
// ("100MB" from CLI / config files) go through parseSize for
// size-shorthand support. The typeof check matters because parseSize
// calls `.match` on its input and would throw on a raw number. Default
// matches the previous hard-coded 10 MiB cap. See #474.
const bodyLimit = options.bodyLimit == null
? defaults.bodyLimit
: (typeof options.bodyLimit === 'number' ? options.bodyLimit : parseSize(options.bodyLimit));
const fastifyOptions = {
logger: loggerEnabled ? { level: options.logLevel || 'info' } : false,
disableRequestLogging: true,
trustProxy: true,
// Force close connections on server.close() (useful for tests with WebSockets)
forceCloseConnections: options.forceCloseConnections ?? false,
// Handle raw body for non-JSON content
bodyLimit: 10 * 1024 * 1024, // 10MB
// Cap raw body size (see resolution above; configurable via
// --body-limit / JSS_BODY_LIMIT / createServer({ bodyLimit })).
bodyLimit,
// Gracefully handle client TCP errors (ECONNRESET, EPIPE, etc.)
clientErrorHandler: (err, socket) => {
if (err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.code === 'ECONNABORTED') {
Expand Down
105 changes: 105 additions & 0 deletions test/body-limit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Configurable bodyLimit (#474).
*
* Before #474 the per-request body cap was hard-coded to 10 MiB inside
* createServer's fastifyOptions, so any operator who wanted to accept
* larger `git push` payloads (e.g. an established app repo with several
* MB of history) had no way to raise it.
*
* The fix exposes `bodyLimit` as a createServer option / CLI flag
* (`--body-limit`) / env var (`JSS_BODY_LIMIT`) / config key, accepting
* either a number (bytes) or a size string ("100MB", "1GB", …).
*
* Tests here verify the surface end-to-end — server actually rejects
* over-size requests with 413, AND the size-string parsing path works
* the same as the numeric path. The default (10 MiB) is covered
* implicitly by every other test in the suite continuing to pass.
*/

import { describe, it, before, after, afterEach } from 'node:test';
import assert from 'node:assert';
import { createServer } from '../src/server.js';
import fs from 'fs-extra';

const TEST_DATA_DIR = './test-data-body-limit';

let server;
let baseUrl;
let originalDataRoot;

async function startWith(bodyLimit) {
await fs.emptyDir(TEST_DATA_DIR);
Comment on lines +30 to +31

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 14f0862 via the snapshot/restore added in the paired threads. before captures the pre-suite value, after restores it.

server = createServer({
logger: false,
forceCloseConnections: true,
root: TEST_DATA_DIR,
bodyLimit,
});
await server.listen({ port: 0, host: '127.0.0.1' });
const address = server.server.address();
baseUrl = `http://127.0.0.1:${address.port}`;
}

describe('configurable bodyLimit (#474)', () => {
before(() => {
// createServer({ root }) mutates process.env.DATA_ROOT
// (src/server.js:180). Snapshot the previous value so we can
// restore it after the suite — otherwise the test directory
// leaks into any subsequent test that reads DATA_ROOT.
originalDataRoot = process.env.DATA_ROOT;
});

afterEach(async () => {
if (server) {
await server.close();
server = null;
}
await fs.remove(TEST_DATA_DIR);
});
Comment on lines +52 to +58

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 14f0862 via the same snapshot/restore added in the paired thread. after hook restores DATA_ROOT (or deletes it if it was unset before the suite ran).


after(() => {
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
else process.env.DATA_ROOT = originalDataRoot;
});

it('rejects requests larger than a numeric bodyLimit with 413', async () => {
await startWith(100); // 100 bytes
// Body well over the cap — Fastify's body parser fires the 413
// before any route handler (or auth) runs.
const oversized = 'x'.repeat(500);
const res = await fetch(`${baseUrl}/anywhere`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: oversized,
});
assert.strictEqual(res.status, 413,
`expected 413 for 500-byte body against a 100-byte limit, got ${res.status}`);
});

it('parses string bodyLimit ("100B") via parseSize (same behaviour as numeric)', async () => {
await startWith('100B');
const oversized = 'x'.repeat(500);
const res = await fetch(`${baseUrl}/anywhere`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: oversized,
});
assert.strictEqual(res.status, 413,
`expected 413 from string-parsed bodyLimit "100B"; got ${res.status}`);
});

it('accepts requests within the configured bodyLimit (no 413)', async () => {
await startWith('10KB');
// Small body, well under the 10KB cap — must NOT 413. (We don't care
// which downstream status fires — auth/404/etc. are fine; only that
// the body-parser doesn't reject.)
const small = 'x'.repeat(100);
const res = await fetch(`${baseUrl}/anywhere`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: small,
});
assert.notStrictEqual(res.status, 413,
`100-byte body under a 10KB cap should not 413; got ${res.status}`);
});
});