diff --git a/src/backend/clients/database/migrations/sqlite/0055_username_nocase_unique.sql b/src/backend/clients/database/migrations/sqlite/0055_username_nocase_unique.sql
new file mode 100644
index 000000000..7e95ee4b5
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0055_username_nocase_unique.sql
@@ -0,0 +1,31 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Enforce case-insensitive uniqueness on user.username.
+--
+-- SQLite defaults varchar columns to BINARY collation, so without this
+-- index `Admin` and `admin` can coexist as separate rows even though the
+-- reserved-name check and adminOnly gate both treat usernames as
+-- case-insensitive. Prod MySQL already uses ascii_general_ci on this
+-- column; this brings self-hosted SQLite to the same invariant.
+--
+-- If this CREATE fails because the DB already contains case-collision
+-- duplicates, resolve them manually before re-running the migration —
+-- there is no safe automatic merge of two user accounts.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username_nocase
+ ON user(username COLLATE NOCASE)
+ WHERE username IS NOT NULL;
diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts
index c3547339c..f4d8ab345 100644
--- a/src/backend/controllers/fs/LegacyFSController.ts
+++ b/src/backend/controllers/fs/LegacyFSController.ts
@@ -57,6 +57,16 @@ type RouterCache = Map;
const additionalRoutePaths: Record = {};
+// Legacy `/batch` multipart upload caps. Each file is buffered fully into
+// memory before any quota / storage check runs, so without these limits an
+// authenticated caller could grow the process heap proportional to whatever
+// they sent. Streaming uploads go through `/writeFile`; this path is for
+// pre-v2 clients only.
+const BATCH_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB per file
+const BATCH_MAX_FILES = 64;
+const BATCH_MAX_PARTS = 256;
+const BATCH_MAX_FIELD_SIZE = 1 * 1024 * 1024; // 1 MiB per operation/fileinfo JSON
+
async function loadAdditionalRouter(
key: string,
): Promise {
@@ -2012,7 +2022,46 @@ export class LegacyFSController extends PuterController {
}> = [];
const fileinfos: Array> = [];
let parseError: Error | null = null;
- const bb = Busboy({ headers: req.headers });
+ const bb = Busboy({
+ headers: req.headers,
+ limits: {
+ fileSize: BATCH_MAX_FILE_SIZE,
+ files: BATCH_MAX_FILES,
+ parts: BATCH_MAX_PARTS,
+ fieldSize: BATCH_MAX_FIELD_SIZE,
+ },
+ });
+
+ // Busboy emits these `*Limit` events when a configured cap is
+ // hit. Capture the first one as a 413 so callers get a clean
+ // signal instead of a silently-truncated upload.
+ bb.on('filesLimit', () => {
+ if (!parseError) {
+ parseError = new HttpError(
+ 413,
+ `Too many files in batch (max ${BATCH_MAX_FILES})`,
+ { legacyCode: 'too_large' as never },
+ );
+ }
+ });
+ bb.on('partsLimit', () => {
+ if (!parseError) {
+ parseError = new HttpError(
+ 413,
+ `Too many parts in batch (max ${BATCH_MAX_PARTS})`,
+ { legacyCode: 'too_large' as never },
+ );
+ }
+ });
+ bb.on('fieldsLimit', () => {
+ if (!parseError) {
+ parseError = new HttpError(
+ 413,
+ 'Too many fields in batch',
+ { legacyCode: 'too_large' as never },
+ );
+ }
+ });
bb.on('field', (fieldName, value) => {
try {
@@ -2039,8 +2088,24 @@ export class LegacyFSController extends PuterController {
// For streaming uploads use the signed `/writeFile` endpoint.
bb.on('file', (_fieldName, stream, info) => {
const chunks: Buffer[] = [];
+ let truncated = false;
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
+ // Busboy emits `limit` after writing the first byte past
+ // `fileSize`. The stream continues being drained so the
+ // multipart parser stays in sync, but we discard the (now
+ // truncated) buffer and mark the batch as failed.
+ stream.on('limit', () => {
+ truncated = true;
+ if (!parseError) {
+ parseError = new HttpError(
+ 413,
+ `File in batch exceeds ${BATCH_MAX_FILE_SIZE} bytes`,
+ { legacyCode: 'too_large' as never },
+ );
+ }
+ });
stream.on('end', () => {
+ if (truncated) return;
files.push({
content: Buffer.concat(chunks),
mimeType:
diff --git a/src/backend/core/http/middleware/gates.test.ts b/src/backend/core/http/middleware/gates.test.ts
index b39f5e76e..90eb0501a 100644
--- a/src/backend/core/http/middleware/gates.test.ts
+++ b/src/backend/core/http/middleware/gates.test.ts
@@ -273,6 +273,35 @@ describe('adminOnlyGate', () => {
'forbidden',
);
});
+
+ it('admits the built-ins regardless of case', () => {
+ // On a SQLite self-host with case-sensitive (BINARY) collation, a
+ // user row could exist with `Admin`/`SYSTEM` capitalization. The
+ // gate must still admit those — case is normalized on both sides.
+ for (const username of ['Admin', 'ADMIN', 'sYsTeM']) {
+ const got = runGate(adminOnlyGate(), {
+ actor: { user: { uuid: 'u-1', username } },
+ });
+ expect(got).toBeUndefined();
+ }
+ });
+
+ it('admits case-mismatched extras (allowlist lowercased on construction)', () => {
+ // Pass an extra in mixed case; the gate must accept the same name
+ // in any case — the on-disk username row may be either, depending
+ // on the DB's column collation.
+ const gate = adminOnlyGate(['Daniel']);
+ expect(
+ runGate(gate, {
+ actor: { user: { uuid: 'u-1', username: 'daniel' } },
+ }),
+ ).toBeUndefined();
+ expect(
+ runGate(gate, {
+ actor: { user: { uuid: 'u-1', username: 'DANIEL' } },
+ }),
+ ).toBeUndefined();
+ });
});
// ── requireVerifiedGate ─────────────────────────────────────────────
diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts
index ee73ca513..f188266dc 100644
--- a/src/backend/core/http/middleware/gates.ts
+++ b/src/backend/core/http/middleware/gates.ts
@@ -159,10 +159,16 @@ export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
export const adminOnlyGate = (
extras: readonly string[] = [],
): RequestHandler => {
- const allowList = new Set([...DEFAULT_ADMIN_USERNAMES, ...extras]);
+ // Match the case-insensitivity guarantee of the username column
+ // (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
+ // raw-case here would let a stored `Admin` bypass the lowercase allowlist
+ // on any backend that lets case-collision rows exist.
+ const allowList = new Set(
+ [...DEFAULT_ADMIN_USERNAMES, ...extras].map((u) => u.toLowerCase()),
+ );
return (req, _res, next) => {
const username = req.actor?.user.username;
- if (!username || !allowList.has(username)) {
+ if (!username || !allowList.has(username.toLowerCase())) {
next(
new HttpError(403, 'Only admins may request this resource', {
legacyCode: 'forbidden',
diff --git a/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts b/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts
index 83737539f..894169ef5 100644
--- a/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts
+++ b/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts
@@ -140,6 +140,21 @@ export class VoiceChangerDriver extends PuterDriver {
throw new HttpError(400, '`voice` is required', {
legacyCode: 'bad_request',
});
+ // `voiceId` lands in the request URL path; `modelId` lands in a
+ // multipart field. Both are forwarded to ElevenLabs with our
+ // long-lived API key, so anything other than a strict alphanumeric
+ // shape lets a caller steer the request at a different endpoint or
+ // inject parameters. ElevenLabs voice/model IDs are always
+ // `[A-Za-z0-9_-]+` in practice.
+ const ID_REGEX = /^[A-Za-z0-9_-]+$/;
+ if (!ID_REGEX.test(voiceId))
+ throw new HttpError(400, '`voice` must be alphanumeric', {
+ legacyCode: 'bad_request',
+ });
+ if (!ID_REGEX.test(modelId))
+ throw new HttpError(400, '`model` must be alphanumeric', {
+ legacyCode: 'bad_request',
+ });
// Metering: estimate duration from file size if we don't parse metadata.
// 16 kbit/s is a safe lower bound for speech audio; pre-check credits