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
4 changes: 2 additions & 2 deletions ecosystem-tests/vercel-edge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"edge-runtime": "edge-runtime",
"vercel": "vercel",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:ci:dev": "start-server-and-test dev http://localhost:3000 test",
"test:ci": "start-server-and-test start http://localhost:3000 test"
"test:ci:dev": "VERCEL_EDGE_TEST_TOKEN=$(node -e \"process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))\") start-server-and-test dev http://localhost:3000 test",
"test:ci": "VERCEL_EDGE_TEST_TOKEN=$(node -e \"process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))\") start-server-and-test start http://localhost:3000 test"
},
"dependencies": {
"@ai-sdk/react": "4.0.20",
Expand Down
42 changes: 42 additions & 0 deletions ecosystem-tests/vercel-edge/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

const minimumTokenLength = 32;
const maximumRequestBodyBytes = 64 * 1024;

function matchesAuthorization(actual: string, expected: string): boolean {
let difference = Math.abs(actual.length - expected.length);

for (let index = 0; index < expected.length; index += 1) {
difference += Number(actual[index] !== expected[index]);
}

return difference === 0;
}

export function middleware(request: NextRequest): NextResponse {
const token = process.env.VERCEL_EDGE_TEST_TOKEN;
const authorization = request.headers.get('authorization') ?? '';

if (
!token ||
token.length < minimumTokenLength ||
!matchesAuthorization(authorization, `Bearer ${token}`)
) {
return new NextResponse('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Bearer' },
});
}

const contentLength = request.headers.get('content-length');
if (contentLength && Number(contentLength) > maximumRequestBodyBytes) {
return new NextResponse('Payload Too Large', { status: 413 });
}
Comment thread
HAYDEN-OAI marked this conversation as resolved.
Resolved

return NextResponse.next();
}

export const config = {
matcher: '/api/:path*',
};
14 changes: 12 additions & 2 deletions ecosystem-tests/vercel-edge/src/pages/ai-streaming.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ const transport = new DefaultChatTransport({ api: '/api/vercel-ai-streaming' });

export default function Chat() {
const [input, setInput] = useState('');
const [accessToken, setAccessToken] = useState('');
const { messages, sendMessage } = useChat({ transport });

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!input.trim()) {return;}
if (!input.trim() || !accessToken.trim()) {return;}

void sendMessage({ text: input });
void sendMessage({ text: input }, { headers: { authorization: `Bearer ${accessToken.trim()}` } });
setInput('');
};

Expand All @@ -30,6 +31,15 @@ export default function Chat() {
))}

<form onSubmit={handleSubmit}>
<label>
Test access token
<input
autoComplete="off"
onChange={(event) => setAccessToken(event.target.value)}
type="password"
value={accessToken}
/>
</label>
<label>
Say something...
<input
Expand Down
1 change: 1 addition & 0 deletions ecosystem-tests/vercel-edge/src/pages/api/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default async function handler(request: NextRequest) {
const result = await openai.completions.create({
prompt: 'Reply with exactly this text and nothing else: This is a test',
model: 'gpt-3.5-turbo-instruct',
max_tokens: 128,
});
return NextResponse.json(result);
}
1 change: 1 addition & 0 deletions ecosystem-tests/vercel-edge/src/pages/api/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default async function handler(request: NextRequest) {
const stream = await openai.completions.create({
prompt: 'Reply with exactly this text and nothing else: This is a test',
model: 'gpt-3.5-turbo-instruct',
max_tokens: 128,
stream: true,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,71 @@ export const config = {
],
};

const maximumMessages = 32;
const maximumMessageCharacters = 16_384;
const maximumRequestBodyBytes = 64 * 1024;

async function cancelOversizedRequest(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
try {
await reader.cancel();
} catch {
// Cleanup failures must not mask the original payload-size rejection.
}
}

async function readRequestBody(request: NextRequest): Promise<string | null> {
const reader = request.body?.getReader();
if (!reader) {
return '';
}

const body = new Uint8Array(maximumRequestBodyBytes);
let length = 0;

try {
while (true) {
// oxlint-disable-next-line eslint/no-await-in-loop -- A bounded stream must consume chunks sequentially.
const { done, value } = await reader.read();
if (done) {
break;
}
if (value.byteLength > maximumRequestBodyBytes - length) {
void cancelOversizedRequest(reader);
return null;
}

body.set(value, length);
length += value.byteLength;
}
} finally {
reader.releaseLock();
}

return new TextDecoder().decode(body.subarray(0, length));
}

export default async function handler(request: NextRequest) {
const openai = new OpenAI();
const body = await readRequestBody(request);
if (body === null) {
return new Response('Payload Too Large', { status: 413 });
}

const { messages }: { messages: UIMessage[] } = await request.json();
const { messages }: { messages: UIMessage[] } = JSON.parse(body);

if (!Array.isArray(messages)) {
return new Response('Invalid messages', { status: 400 });
}
if (messages.length > maximumMessages) {
return new Response('Too many messages', { status: 413 });
}

let totalCharacters = 0;
const openAIMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = messages.map((message) => {
const content = message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('');
totalCharacters += content.length;

switch (message.role) {
case 'system': {
Expand All @@ -37,8 +93,14 @@ export default async function handler(request: NextRequest) {
}
});

if (totalCharacters > maximumMessageCharacters) {
return new Response('Messages are too large', { status: 413 });
}

const openai = new OpenAI();
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
max_tokens: 128,
stream: true,
messages: openAIMessages,
});
Expand Down
18 changes: 13 additions & 5 deletions ecosystem-tests/vercel-edge/src/uploadWebApiTestCases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,32 +143,40 @@ export function uploadWebApiTestCases({

const fineTune = `{"prompt": "<prompt text>", "completion": "<ideal generated text>"}`;

async function expectAndDeleteUploadedFile(file: { filename: string; id: string }) {
try {
expectEqual(file.filename, 'finetune.jsonl');
} finally {
await client.files.delete(file.id);
}
}

it('toFile handles string', async () => {
// @ts-ignore this only doesn't error in vercel build...
const file = await toFile(fineTune, 'finetune.jsonl');
const result = await client.files.create({ file, purpose: 'fine-tune' });
expectEqual(result.filename, 'finetune.jsonl');
await expectAndDeleteUploadedFile(result);
});
it('toFile handles Blob', async () => {
const result = await client.files.create({
file: await toFile(new Blob([fineTune]), 'finetune.jsonl'),
purpose: 'fine-tune',
});
expectEqual(result.filename, 'finetune.jsonl');
await expectAndDeleteUploadedFile(result);
});
it('toFile handles Uint8Array', async () => {
const result = await client.files.create({
file: await toFile(new TextEncoder().encode(fineTune), 'finetune.jsonl'),
purpose: 'fine-tune',
});
expectEqual(result.filename, 'finetune.jsonl');
await expectAndDeleteUploadedFile(result);
});
it('toFile handles ArrayBuffer', async () => {
const result = await client.files.create({
file: await toFile(new TextEncoder().encode(fineTune).buffer, 'finetune.jsonl'),
purpose: 'fine-tune',
});
expectEqual(result.filename, 'finetune.jsonl');
await expectAndDeleteUploadedFile(result);
});
if (runtime !== 'edge') {
// this fails in edge for some reason
Expand All @@ -177,7 +185,7 @@ export function uploadWebApiTestCases({
file: await toFile(new DataView(new TextEncoder().encode(fineTune).buffer), 'finetune.jsonl'),
purpose: 'fine-tune',
});
expectEqual(result.filename, 'finetune.jsonl');
await expectAndDeleteUploadedFile(result);
});
}
}
117 changes: 117 additions & 0 deletions ecosystem-tests/vercel-edge/tests/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { NextRequest } from 'next/server';
import { config, middleware } from '../src/middleware';

const token = 'test-token-with-at-least-thirty-two-characters';
const originalToken = process.env.VERCEL_EDGE_TEST_TOKEN;
const protectedRoutes = [
'/api/query-params',
'/api/vercel-ai-streaming',
'/api/response',
'/api/streaming',
'/api/transcribe',
'/api/edge-test',
'/api/node-test',
];

beforeEach(() => {
process.env.VERCEL_EDGE_TEST_TOKEN = token;
});

afterAll(() => {
if (originalToken === undefined) {
delete process.env.VERCEL_EDGE_TEST_TOKEN;
} else {
process.env.VERCEL_EDGE_TEST_TOKEN = originalToken;
}
});

it('applies the authentication boundary to every API route', () => {
expect(config.matcher).toBe('/api/:path*');
});

it.each(protectedRoutes)('rejects an unauthenticated request to %s', (path) => {
const response = middleware(new NextRequest(`https://example.com${path}`));

expect(response.status).toBe(401);
expect(response.headers.get('www-authenticate')).toBe('Bearer');
});

it.each(['', 'Bearer wrong-token', `Basic ${token}`, token, `Bearer ${token} extra`])(
'rejects the invalid authorization header %p',
(authorization) => {
const request = new NextRequest('https://example.com/api/query-params', {
headers: { authorization },
});

expect(middleware(request).status).toBe(401);
},
);

it('fails closed when its separate access token is not configured', () => {
delete process.env.VERCEL_EDGE_TEST_TOKEN;
const request = new NextRequest('https://example.com/api/query-params', {
headers: { authorization: `Bearer ${token}` },
});

expect(middleware(request).status).toBe(401);
});

it('rejects an insecure access token configuration', () => {
process.env.VERCEL_EDGE_TEST_TOKEN = 'too-short';
const request = new NextRequest('https://example.com/api/query-params', {
headers: { authorization: 'Bearer too-short' },
});

expect(middleware(request).status).toBe(401);
});

it('does not treat proxy or middleware headers as authorization', () => {
const request = new NextRequest('https://example.com/api/query-params', {
headers: {
'x-forwarded-host': 'localhost',
'x-middleware-subrequest': 'src/middleware:src/middleware:src/middleware',
},
});

expect(middleware(request).status).toBe(401);
});

it('forwards requests carrying the configured bearer token', () => {
const request = new NextRequest('https://example.com/api/query-params', {
headers: { authorization: `Bearer ${token}` },
});
const response = middleware(request);

expect(response.status).toBe(200);
expect(response.headers.get('x-middleware-next')).toBe('1');
});

it('rejects an authenticated request with an oversized declared body', () => {
const request = new NextRequest('https://example.com/api/vercel-ai-streaming', {
headers: { authorization: `Bearer ${token}`, 'content-length': '65537' },
});

expect(middleware(request).status).toBe(413);
});

it('checks authorization before inspecting an oversized body', () => {
const request = new NextRequest('https://example.com/api/vercel-ai-streaming', {
headers: { 'content-length': '65537' },
});

expect(middleware(request).status).toBe(401);
});

it('rejects unauthenticated streaming requests before consuming their bodies', () => {
const request = new NextRequest('https://example.com/api/vercel-ai-streaming', {
method: 'POST',
body: new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(65_537));
},
}),
});

expect(middleware(request).status).toBe(401);
expect(request.bodyUsed).toBe(false);
});
Loading
Loading