Files
WRNexusJS/packages/uploader/src/operations.ts
T
ClintchizandClaude Opus 5 c64434a131 fix(security): close SSRF, credential-leak, and auth bypass findings in 0.8.4
Audit of 0.8.4 found the repo's own gates green, so these came from manual
review; each is covered by a new regression test.

security/fetch.ts
- safeFetch re-attached Authorization/Cookie on a same-origin redirect that
  followed a cross-origin hop (a -> b -> b), handing credentials to the second
  host. Compare against the origin the caller trusted, not the previous hop.
- The private-network guard resolved the host, approved it, then let fetch
  resolve again, so a low-TTL record could answer public for the check and
  private for the connection. Pin the connection to the validated address,
  preserving Host and TLS serverName. Opt out with pinDns: false.
- 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as
  private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15
  and 192.0.0/24.

security/url.ts
- sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path,
  bypassing the host checks it had just run; in an href that navigates
  cross-origin. Resolve protocol-relative input instead.

dev-server/gateway.ts
- Malformed base64 in an Authorization header threw out of checkAuth on an
  unauthenticated path. Fail closed.
- split(":", 2) truncated passwords at the first colon, so a password
  containing ":" could never authenticate.
- The credential compare short-circuited on length mismatch, leaking length
  by timing. Extracted as verifyBasicAuth so it is testable.

authz/index.ts
- Namespace wildcards only matched the first segment, so "post:comment:*"
  did not grant "post:comment:delete". Match at every depth.

uploader/operations.ts
- Validate transcoder dimensions and bitrate rather than trusting the declared
  type, and reject ".." path segments.

package.json
- The brace-expansion override pinned 5.0.8, which is inside the advisory
  range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean.

Verified: check:production passes (typecheck, lint, 1033 tests, format,
ASVS, public-API baseline, editor checks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:57:22 +05:30

213 lines
7.5 KiB
TypeScript

import type { PutMeta, StorageDriver } from "./driver.ts";
export interface QuotaUsage {
owner: string;
bytes: number;
objects: number;
updatedAt: number;
}
export interface QuotaStore {
get(owner: string): Promise<QuotaUsage>;
reserve(
owner: string,
bytes: number,
limits: { bytes: number; objects?: number },
): Promise<boolean>;
release(owner: string, bytes: number): Promise<void>;
}
export function memoryQuotaStore(): QuotaStore {
const values = new Map<string, QuotaUsage>();
return {
async get(owner) {
return structuredClone(
values.get(owner) ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() },
);
},
async reserve(owner, bytes, limits) {
if (!Number.isInteger(bytes) || bytes < 0)
throw new RangeError("Quota bytes must be non-negative");
const current = values.get(owner) ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() };
if (
current.bytes + bytes > limits.bytes ||
current.objects + 1 > (limits.objects ?? Number.MAX_SAFE_INTEGER)
)
return false;
values.set(owner, {
owner,
bytes: current.bytes + bytes,
objects: current.objects + 1,
updatedAt: Date.now(),
});
return true;
},
async release(owner, bytes) {
const current = values.get(owner);
if (!current) return;
values.set(owner, {
...current,
bytes: Math.max(0, current.bytes - Math.max(0, bytes)),
objects: Math.max(0, current.objects - 1),
updatedAt: Date.now(),
});
},
};
}
export interface QuotaSqlClient {
query<T = any>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>;
}
/** PostgreSQL quota accounting using a single atomic conditional upsert. */
export function postgresQuotaStore(
db: QuotaSqlClient,
table = "wrnexus_storage_quota",
): QuotaStore {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error("Invalid quota table name");
return {
async get(owner) {
const result = await db.query<QuotaUsage>(
`SELECT owner,bytes,objects,updated_at AS "updatedAt" FROM ${table} WHERE owner=$1`,
[owner],
);
return result.rows[0] ?? { owner, bytes: 0, objects: 0, updatedAt: Date.now() };
},
async reserve(owner, bytes, limits) {
const result = await db.query(
`INSERT INTO ${table} (owner,bytes,objects,updated_at) VALUES ($1,$2,1,$5) ON CONFLICT (owner) DO UPDATE SET bytes=${table}.bytes+$2,objects=${table}.objects+1,updated_at=$5 WHERE ${table}.bytes+$2 <= $3 AND ${table}.objects+1 <= $4 RETURNING owner`,
[owner, bytes, limits.bytes, limits.objects ?? 2_147_483_647, Date.now()],
);
return result.rows.length === 1;
},
async release(owner, bytes) {
await db.query(
`UPDATE ${table} SET bytes=GREATEST(0,bytes-$2),objects=GREATEST(0,objects-1),updated_at=$3 WHERE owner=$1`,
[owner, bytes, Date.now()],
);
},
};
}
export const POSTGRES_QUOTA_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_storage_quota (owner text PRIMARY KEY, bytes bigint NOT NULL DEFAULT 0, objects integer NOT NULL DEFAULT 0, updated_at bigint NOT NULL);`;
export interface MultipartObjectClient {
create(key: string, meta: PutMeta): Promise<string>;
uploadPart(uploadId: string, key: string, part: number, bytes: Uint8Array): Promise<string>;
complete(
uploadId: string,
key: string,
parts: Array<{ part: number; etag: string }>,
): Promise<void>;
abort(uploadId: string, key: string): Promise<void>;
}
export async function multipartUpload(
client: MultipartObjectClient,
key: string,
bytes: Uint8Array,
meta: PutMeta,
options: { partBytes?: number; concurrency?: number } = {},
): Promise<void> {
const partBytes = options.partBytes ?? 8 * 1024 * 1024;
const concurrency = options.concurrency ?? 4;
if (partBytes < 5 * 1024 * 1024) throw new RangeError("Multipart parts must be at least 5 MiB");
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32)
throw new RangeError("Multipart concurrency must be between 1 and 32");
const uploadId = await client.create(key, meta);
const chunks = Array.from({ length: Math.ceil(bytes.length / partBytes) }, (_, index) => ({
part: index + 1,
bytes: bytes.slice(index * partBytes, (index + 1) * partBytes),
}));
const completed: Array<{ part: number; etag: string }> = [];
try {
for (let offset = 0; offset < chunks.length; offset += concurrency) {
completed.push(
...(await Promise.all(
chunks.slice(offset, offset + concurrency).map(async (chunk) => ({
part: chunk.part,
etag: await client.uploadPart(uploadId, key, chunk.part, chunk.bytes),
})),
)),
);
}
await client.complete(uploadId, key, completed);
} catch (error) {
await client.abort(uploadId, key);
throw error;
}
}
export interface TemporaryObject {
key: string;
expiresAt: number;
}
export function createTemporaryObjectCleaner(
driver: StorageDriver,
options: { now?: () => number; limit?: number } = {},
) {
const objects = new Map<string, number>();
const now = options.now ?? Date.now;
const limit = options.limit ?? 10_000;
return {
track(key: string, ttlMs: number) {
if (objects.size >= limit) throw new Error("WRN-UPLOAD-TEMP-CAPACITY");
if (ttlMs < 1) throw new RangeError("Temporary TTL must be positive");
objects.set(key, now() + ttlMs);
},
async cleanup(at = now()) {
const due = [...objects].filter(([, expiry]) => expiry <= at);
for (const [key] of due) {
await driver.delete(key);
objects.delete(key);
}
return due.length;
},
snapshot: () => ({
tracked: objects.size,
nextExpiry: objects.size ? Math.min(...objects.values()) : undefined,
}),
};
}
export interface VideoTranscodeOptions {
format: "mp4" | "webm";
width?: number;
height?: number;
videoBitrateKbps?: number;
}
export function ffmpegVideoTranscoder(
options: { executable?: string; spawn?: (args: string[]) => { exited: Promise<number> } } = {},
) {
return async (input: string, output: string, config: VideoTranscodeOptions): Promise<void> => {
const validPath = (value: string) =>
/^[\w .:\\/-]+$/.test(value) && !value.split(/[\\/]/).includes("..");
if (!validPath(input) || !validPath(output)) throw new Error("Invalid video path");
if (config.format !== "mp4" && config.format !== "webm")
throw new Error("Invalid video format");
// These reach an ffmpeg filter string, so reject anything that is not a
// plain positive integer rather than trusting the declared type.
const dimension = (value: number | undefined, name: string): number | undefined => {
if (value === undefined) return undefined;
if (!Number.isInteger(value) || value <= 0 || value > 16384)
throw new Error(`Invalid video ${name}`);
return value;
};
const width = dimension(config.width, "width");
const height = dimension(config.height, "height");
const bitrate = dimension(config.videoBitrateKbps, "bitrate");
const args = [
options.executable ?? "ffmpeg",
"-y",
"-i",
input,
...(width || height ? ["-vf", `scale=${width ?? -2}:${height ?? -2}`] : []),
...(bitrate ? ["-b:v", `${bitrate}k`] : []),
"-f",
config.format,
output,
];
const child = options.spawn?.(args) ?? Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
if ((await child.exited) !== 0) throw new Error("WRN-VIDEO-TRANSCODE-FAILED");
};
}