Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/strict-byte-ranges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Serve malformed byte-range requests as full downloads instead of partial responses.
11 changes: 4 additions & 7 deletions packages/kap-server/src/lib/httpRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,10 @@ export function parseRangeHeader(
size: number,
): { start: number; end: number; length: number } | null {
if (raw === undefined) return null;
if (!raw.startsWith('bytes=')) return null;
const spec = raw.slice('bytes='.length);
if (spec.includes(',')) return null;
const dash = spec.indexOf('-');
if (dash < 0) return null;
const leftRaw = spec.slice(0, dash);
const rightRaw = spec.slice(dash + 1);
const match = /^bytes=(\d*)-(\d*)$/i.exec(raw.trim());
if (match === null) return null;
const leftRaw = match[1] ?? '';
const rightRaw = match[2] ?? '';
if (leftRaw === '' && rightRaw === '') return null;
let start: number;
let end: number;
Expand Down
48 changes: 4 additions & 44 deletions packages/kap-server/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '@moonshot-ai/agent-core-v2';
import { z } from 'zod';

import { parseRangeHeader, pickHeader } from '../lib/httpRange';
import { requestLog } from '../lib/requestLog';
import { defineRoute } from '../middleware/defineRoute';
import { ErrorCode } from '../protocol/error-codes';
Expand Down Expand Up @@ -180,13 +181,10 @@ export function registerFilesRoutes(app: FilesRouteHost, core: Scope): void {
// Browsers load <video>/<audio> via byte-range requests (Range: bytes=…).
// Without 206 Partial Content + Content-Range the media stalls at 0:00
// and refuses to play or seek, so honor Range when the client sends one.
const range = parseRange(
readRangeHeader((req as unknown as FastifyRequestLike).headers['range']),
size,
);
if (range) {
const range = parseRangeHeader(pickHeader(req.headers, 'range'), size);
if (range !== null) {
r.header('content-range', `bytes ${range.start}-${range.end}/${size}`)
.header('content-length', range.end - range.start + 1)
.header('content-length', range.length)
.code(206);
return r.send(file.stream(range)) as unknown as void;
}
Expand Down Expand Up @@ -285,48 +283,10 @@ function readFieldNumber(field: unknown): number | undefined {
return undefined;
}

function readRangeHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}

function buildContentDisposition(name: string, mediaType?: string): string {
const disposition = /^(image|video|audio)\//.test(mediaType ?? '') ? 'inline' : 'attachment';
if (/^[\w. ()+[\]-]+$/.test(name)) {
return `${disposition}; filename="${name}"`;
}
return disposition;
}

interface ByteRange {
start: number;
end: number;
}

/** Parse a `Range: bytes=start-end` header against the file size. Returns
* undefined for a missing / malformed / unsatisfiable range, in which case the
* caller serves the whole file with 200 (browsers accept that response). */
function parseRange(header: string | undefined, size: number): ByteRange | undefined {
if (!header || size <= 0) return undefined;
const m = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
if (!m) return undefined;
const startStr = m[1]!;
const endStr = m[2]!;
if (startStr === '' && endStr === '') return undefined;

let start: number;
let end: number;
if (startStr === '') {
// Suffix range: `bytes=-N` -> the last N bytes.
const suffix = Number(endStr);
if (!Number.isFinite(suffix) || suffix <= 0) return undefined;
start = Math.max(size - suffix, 0);
end = size - 1;
} else {
start = Number(startStr);
if (!Number.isFinite(start) || start < 0 || start >= size) return undefined;
end = endStr === '' ? size - 1 : Number(endStr);
if (!Number.isFinite(end) || end < 0) return undefined;
}
if (start > end) return undefined;
return { start, end: Math.min(end, size - 1) };
}
18 changes: 18 additions & 0 deletions packages/kap-server/test/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { parseRangeHeader } from '../src/lib/httpRange';
import { type RunningServer, startServer } from '../src/start';

let home: string;
Expand Down Expand Up @@ -80,6 +81,14 @@ interface Envelope<T = unknown> {
details?: unknown;
}

describe('parseRangeHeader', () => {
it('rejects malformed numeric ranges instead of parsing a prefix', () => {
expect(parseRangeHeader('bytes=1abc-3', 10)).toBeNull();
expect(parseRangeHeader('bytes=1-3xyz', 10)).toBeNull();
expect(parseRangeHeader('bytes=1-3', 10)).toEqual({ start: 1, end: 3, length: 3 });
});
});

function buildMultipart(parts: {
file: { fieldName: string; filename: string; contentType: string; data: Buffer };
fields?: Array<{ name: string; value: string }>;
Expand Down Expand Up @@ -301,6 +310,15 @@ describe('POST /api/v1/files (server-v2)', () => {
expect(tail.statusCode).toBe(206);
expect(tail.headers['content-range']).toBe(`bytes 30-${data.length - 1}/${data.length}`);
expect(tail.rawPayload).toEqual(data.subarray(30));

const malformed = await appOf(r).inject({
method: 'GET',
url: `/api/v1/files/${meta.id}`,
headers: { range: 'bytes=4abc-9' },
});
expect(malformed.statusCode).toBe(200);
expect(malformed.headers['content-range']).toBeUndefined();
expect(malformed.rawPayload).toEqual(data);
});

it('missing file part → 40001 validation error', async () => {
Expand Down