This commit is contained in:
@@ -97,7 +97,7 @@ describe('GitHub release lookup', () => {
|
||||
normal,
|
||||
{ ...normal, id: 201, name: 'source.zip' },
|
||||
{ ...normal, id: 202, name: '../evil.dll' },
|
||||
{ ...normal, id: 203, size: 65 * 1024 * 1024 },
|
||||
{ ...normal, id: 203, size: 32 * 1024 * 1024 + 1 },
|
||||
{ ...normal, id: 204, state: 'new' }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import {
|
||||
JSON_BODY_LIMIT,
|
||||
ARTIFACT_FORM_LIMIT,
|
||||
BETA_REVISION_FORM_LIMIT
|
||||
} from '../../src/lib/server/request-body.js';
|
||||
import { MAX_ARTIFACT_BYTES } from '../../src/lib/artifact-limits.js';
|
||||
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({
|
||||
kick: vi.fn(),
|
||||
kickProvisioning: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/lib/server/pipeline/beta-plan-runner.js', () => ({ plannedBetaTick: vi.fn() }));
|
||||
|
||||
type Event = { request: Request; url: URL; params: { id: string } };
|
||||
const routes = import.meta.glob<Record<string, (event: Event) => Promise<Response>>>(
|
||||
'../../src/routes/api/**/+server.ts'
|
||||
);
|
||||
let fixture: Fixture;
|
||||
let scriptId: number;
|
||||
let betaId: number;
|
||||
const token = 'ec_telemetry_0123456789abcdef0123456789abcdef';
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('EC_TELEMETRY_TOKEN', token);
|
||||
fixture = await newFixture();
|
||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { script, version } = createScript({
|
||||
name: 'HUD',
|
||||
targetRelPath: 'hud.gsc',
|
||||
content: Buffer.from('main(){}')
|
||||
});
|
||||
scriptId = script.id;
|
||||
betaId = Number(
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'manual', 'running')"
|
||||
)
|
||||
.run(script.id, version.id).lastInsertRowid
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
function oversizedRequest(method: string, limit: number, declared: boolean) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
};
|
||||
if (declared) headers['Content-Length'] = String(limit + 1);
|
||||
return new Request('http://localhost', {
|
||||
method,
|
||||
headers,
|
||||
body: declared ? '{}' : ' '.repeat(limit + 1)
|
||||
});
|
||||
}
|
||||
|
||||
describe('JSON route size enforcement', () => {
|
||||
it.each([
|
||||
['autoscaling', 'POST'],
|
||||
['betas', 'POST'],
|
||||
['betas/[id]/promote', 'POST'],
|
||||
['betas/[id]/retry', 'POST'],
|
||||
['betas/[id]/test', 'POST'],
|
||||
['betas/presets', 'POST'],
|
||||
['blueprints', 'POST'],
|
||||
['blueprints/[id]', 'PUT'],
|
||||
['configs/inspect', 'POST'],
|
||||
['deployment-collections', 'POST'],
|
||||
['deployment-collections/preview', 'POST'],
|
||||
['deployments/house-party', 'POST'],
|
||||
['deployments/preview', 'POST'],
|
||||
['drift', 'POST'],
|
||||
['maintenance', 'POST'],
|
||||
['maintenance/[id]', 'POST'],
|
||||
['plugins/github/import', 'POST'],
|
||||
['plutonium', 'POST'],
|
||||
['provisioning', 'POST'],
|
||||
['rebalancing', 'POST'],
|
||||
['scripts/[id]/scope', 'POST'],
|
||||
['server-keys', 'PUT'],
|
||||
['server-keys', 'DELETE'],
|
||||
['servers', 'POST'],
|
||||
['settings', 'PUT'],
|
||||
['settings', 'PATCH'],
|
||||
['settings/retention', 'POST'],
|
||||
['settings/retention/preview', 'POST'],
|
||||
['telemetry', 'POST'],
|
||||
['watchdog', 'POST'],
|
||||
['watchdog/[id]', 'POST']
|
||||
])(
|
||||
'%s %s preserves HTTP 413 for declared and undeclared oversized bodies',
|
||||
async (path, method) => {
|
||||
if (path === 'betas/[id]/test') {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db()
|
||||
.prepare('INSERT INTO beta_test_plans (beta_id, plan) VALUES (?, ?)')
|
||||
.run(betaId, '{}');
|
||||
}
|
||||
const route = await routes[`../../src/routes/api/${path}/+server.ts`]();
|
||||
for (const declared of [true, false]) {
|
||||
await expect(
|
||||
route[method]({
|
||||
request: oversizedRequest(method, JSON_BODY_LIMIT, declared),
|
||||
url: new URL('http://localhost'),
|
||||
params: { id: String(path.startsWith('betas/') ? betaId : scriptId) }
|
||||
})
|
||||
).rejects.toMatchObject({ status: 413 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('retains bad-request responses for malformed JSON', async () => {
|
||||
for (const path of ['servers', 'plugins/github/import', 'settings']) {
|
||||
const method = path === 'settings' ? 'PUT' : 'POST';
|
||||
const route = await routes[`../../src/routes/api/${path}/+server.ts`]();
|
||||
const response = await route[method]({
|
||||
request: new Request('http://localhost', { method, body: '{' }),
|
||||
url: new URL('http://localhost'),
|
||||
params: { id: '' }
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifact upload route limits', () => {
|
||||
it('preserves the malformed form error on beta revisions', async () => {
|
||||
const { POST } = await routes['../../src/routes/api/betas/[id]/revisions/+server.ts']();
|
||||
await expect(
|
||||
POST({
|
||||
request: new Request('http://localhost', { method: 'POST', body: 'invalid form' }),
|
||||
url: new URL('http://localhost'),
|
||||
params: { id: String(betaId) }
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
body: { message: 'Request body must be valid form data.' }
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'scripts',
|
||||
'configs',
|
||||
'plugins',
|
||||
'scripts/inspect',
|
||||
'scripts/[id]/versions',
|
||||
'betas/[id]/revisions'
|
||||
])('%s rejects excessive declared bodies before creating versions', async (path) => {
|
||||
const route = await routes[`../../src/routes/api/${path}/+server.ts`]();
|
||||
const limit = path.startsWith('betas/') ? BETA_REVISION_FORM_LIMIT : ARTIFACT_FORM_LIMIT;
|
||||
await expect(
|
||||
route.POST({
|
||||
request: oversizedRequest('POST', limit, true),
|
||||
url: new URL('http://localhost'),
|
||||
params: { id: String(scriptId) }
|
||||
})
|
||||
).rejects.toMatchObject({ status: 413 });
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM script_versions').get()).toEqual({
|
||||
count: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces the artifact limit for direct creation and version writes as well', async () => {
|
||||
const { createScript, uploadNewVersion, listVersions, getScriptByName } =
|
||||
await import('../../src/lib/server/fleet/scripts.js');
|
||||
const content = Buffer.alloc(MAX_ARTIFACT_BYTES + 1);
|
||||
expect(() =>
|
||||
createScript({ name: 'Too big', targetRelPath: 'large.dll', kind: 'plugin', content })
|
||||
).toThrow(/32 MiB/);
|
||||
expect(() => uploadNewVersion(scriptId, content)).toThrow(/32 MiB/);
|
||||
expect(getScriptByName('Too big')).toBeUndefined();
|
||||
expect(listVersions(scriptId)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
artifactFormBody,
|
||||
boundedBody,
|
||||
jsonBody,
|
||||
BETA_REVISION_FORM_LIMIT
|
||||
} from '../../src/lib/server/request-body.js';
|
||||
import { MAX_ARTIFACT_BYTES } from '../../src/lib/artifact-limits.js';
|
||||
|
||||
function streamedRequest(chunks: Uint8Array[], headers?: HeadersInit) {
|
||||
let index = 0;
|
||||
const pull = vi.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
|
||||
if (index === chunks.length) controller.close();
|
||||
else controller.enqueue(chunks[index++]);
|
||||
});
|
||||
const cancel = vi.fn();
|
||||
const request = new Request('http://localhost', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new ReadableStream({ pull, cancel }, { highWaterMark: 0 }),
|
||||
duplex: 'half'
|
||||
} as RequestInit);
|
||||
return { request, pull, cancel };
|
||||
}
|
||||
|
||||
describe('bounded request bodies', () => {
|
||||
it('rejects excessive Content-Length before reading any bytes', async () => {
|
||||
const { request, pull, cancel } = streamedRequest([Buffer.from('{}')], {
|
||||
'Content-Length': '11'
|
||||
});
|
||||
await expect(jsonBody(request, 10)).rejects.toMatchObject({ status: 413 });
|
||||
expect(pull).not.toHaveBeenCalled();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(request.body?.locked).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, '1'])(
|
||||
'counts actual bytes with Content-Length %s and cancels before the remaining chunks',
|
||||
async (length) => {
|
||||
const { request, pull, cancel } = streamedRequest(
|
||||
[Buffer.from('{"value":'), Buffer.from('"too long"'), Buffer.from('}')],
|
||||
length ? { 'Content-Length': length } : undefined
|
||||
);
|
||||
await expect(jsonBody(request, 10)).rejects.toMatchObject({ status: 413 });
|
||||
expect(pull).toHaveBeenCalledTimes(2);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(request.body?.locked).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('accepts the exact UTF-8 byte limit across tiny chunks', async () => {
|
||||
const bytes = Buffer.from('{"value":"é🍒"}');
|
||||
const chunks = Array.from(bytes, (byte) => Uint8Array.of(byte));
|
||||
await expect(jsonBody(streamedRequest(chunks).request, bytes.length)).resolves.toEqual({
|
||||
value: 'é🍒'
|
||||
});
|
||||
await expect(
|
||||
jsonBody(streamedRequest(chunks).request, bytes.length - 1)
|
||||
).rejects.toMatchObject({
|
||||
status: 413
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves bytes across buffer blocks and a partial final block', async () => {
|
||||
const bytes = Buffer.from('abc'.repeat(50_000));
|
||||
const chunks = [bytes.subarray(0, 7), bytes.subarray(7, 70_000), bytes.subarray(70_000)];
|
||||
await expect(boundedBody(streamedRequest(chunks).request, bytes.length)).resolves.toEqual(
|
||||
bytes
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps malformed and empty JSON distinct from size failures', async () => {
|
||||
for (const body of ['', '{']) {
|
||||
await expect(
|
||||
jsonBody(new Request('http://localhost', { method: 'POST', body }))
|
||||
).rejects.toBeInstanceOf(SyntaxError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bounded artifact forms', () => {
|
||||
it('accepts exact request limits and rejects overflow before parsing the form', async () => {
|
||||
const form = new FormData();
|
||||
form.set('content', new File(['main(){}'], 'hud.gsc'));
|
||||
form.set('notes', 'review');
|
||||
const encoded = new Response(form);
|
||||
const headers = encoded.headers;
|
||||
const bytes = new Uint8Array(await encoded.arrayBuffer());
|
||||
const request = () =>
|
||||
streamedRequest([bytes.subarray(0, 5), bytes.subarray(5)], headers).request;
|
||||
const parsed = await artifactFormBody(request(), bytes.length);
|
||||
expect(parsed.get('notes')).toBe('review');
|
||||
expect(await (parsed.get('content') as File).text()).toBe('main(){}');
|
||||
await expect(artifactFormBody(request(), bytes.length - 1)).rejects.toMatchObject({
|
||||
status: 413
|
||||
});
|
||||
await expect(
|
||||
artifactFormBody(streamedRequest([Buffer.alloc(11)], headers).request, 10)
|
||||
).rejects.toMatchObject({ status: 413 });
|
||||
});
|
||||
|
||||
it('accepts a 32 MiB file plus multipart overhead', async () => {
|
||||
const form = new FormData();
|
||||
form.set('content', new File([new Uint8Array(MAX_ARTIFACT_BYTES)], 'plugin.dll'));
|
||||
const parsed = await artifactFormBody(
|
||||
new Request('http://localhost', { method: 'POST', body: form })
|
||||
);
|
||||
expect((parsed.get('content') as File).size).toBe(MAX_ARTIFACT_BYTES);
|
||||
});
|
||||
|
||||
it.each(['file', 'text'])(
|
||||
'rejects an oversized %s artifact within the overall form limit',
|
||||
async (kind) => {
|
||||
const form = new FormData();
|
||||
if (kind === 'file')
|
||||
form.set(
|
||||
'content',
|
||||
new File([new Uint8Array(MAX_ARTIFACT_BYTES + 1)], 'plugin.dll')
|
||||
);
|
||||
else form.set('text', 'é'.repeat(MAX_ARTIFACT_BYTES / 2 + 1));
|
||||
await expect(
|
||||
artifactFormBody(new Request('http://localhost', { method: 'POST', body: form }))
|
||||
).rejects.toMatchObject({
|
||||
status: 413,
|
||||
body: { message: 'Artifacts must not exceed 32 MiB.' }
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('bounds the combined beta revision body even with no declared length', async () => {
|
||||
const chunk = new Uint8Array(1024 * 1024);
|
||||
const { request, cancel } = streamedRequest(Array(67).fill(chunk), {
|
||||
'Content-Type': 'multipart/form-data; boundary=test'
|
||||
});
|
||||
await expect(artifactFormBody(request, BETA_REVISION_FORM_LIMIT)).rejects.toMatchObject({
|
||||
status: 413
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports malformed multipart bodies as bad requests', async () => {
|
||||
const request = new Request('http://localhost', { method: 'POST', body: 'invalid form' });
|
||||
await expect(artifactFormBody(request)).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user