Skip to content

Commit f718493

Browse files
committed
Improved middleware
1 parent c6ce1ee commit f718493

File tree

3 files changed

+104
-36
lines changed

3 files changed

+104
-36
lines changed

api/index.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const fastify = Fastify({
2121
fastify.addHook('onRequest', (request, reply, done) => {
2222
reply.headers({
2323
'Access-Control-Allow-Origin': '*',
24-
'Access-Control-Allow-Methods': 'GET,POST,PATCH,DELETE'
24+
'Access-Control-Allow-Methods': 'HEAD,GET,POST,PATCH,DELETE'
2525
})
2626
done();
2727
})
@@ -368,15 +368,14 @@ fastify.get('/private/:privateKey/:key', async (request, reply) => {
368368

369369
const streamHandler = async (request, reply) => {
370370
const { key } = request.params;
371-
const fromMiddleware = request.headers['x-middleware'] === middlewareSig;
372371
try {
373-
if (! fromMiddleware) throw new Error('Unauthorized');
374372
let recvBool;
375373
switch (request.method) {
376374
case 'POST': // Using fallthrough! POST and PUT cases run the same code.
377375
case 'PUT':
378376
recvBool = false;
379377
break;
378+
case 'HEAD': // HEAD and GET handled similarly
380379
case 'GET':
381380
recvBool = true;
382381
break;
@@ -398,7 +397,7 @@ const streamHandler = async (request, reply) => {
398397
}
399398
}
400399

401-
fastify.all('/stream/:key', streamHandler);
400+
fastify.all('/pipe/:key', streamHandler);
402401

403402
export default async function handler(req, res) {
404403
await fastify.ready();

middleware.js

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,110 @@ import { Ratelimit } from '@upstash/ratelimit';
1313
import { Redis } from '@upstash/redis';
1414

1515
const middlewareSig = process.env.SECRET; // Secret known to middleware only
16+
const bodyLimit = parseInt(process.env.BODYLIMIT);
1617

1718
const cache = new Map(); // must be outside of your serverless function handler
1819

1920
const ratelimit = new Ratelimit({
2021
redis: new Redis({
2122
url: process.env.UPSTASH_REDIS_REST_URL_CACHE,
22-
token: process.env.UPSTASH_REDIS_REST_TOKEN_CACHE,
23+
token: process.env.UPSTASH_REDIS_REST_TOKEN_CACHE
2324
}),
2425
ephemeralCache: cache,
2526
analytics: false,
2627
limiter: Ratelimit.slidingWindow(parseInt(process.env.RATELIMIT), process.env.RATELIMIT_WINDOW + ' s'),
27-
prefix: "rl:",
28+
prefix: 'rl:',
2829
enableProtection: true
29-
})
30-
31-
// Forwards requests at path /pipe/* to /stream/* in index.js after trimming the request body and headers
32-
// and returns the response. This is done in middleware.js because, unlike index.js, middleware.js doesn't have
33-
// problems with the `Expect: 100-continue` header sent by `curl -T- <url>`. middleware.js also doesn't read the
34-
// request body. index.js also has a set bodyLimit which is incompatible with payloads at /pipe/* of arbitrary size.
35-
// /stream/ path is exposed only to middleware.
36-
// For requests not at path /pipe/*, returns null.
37-
async function pipeToStream(request) {
38-
const pipeUrl = new URL(request.url);
39-
if (!pipeUrl.pathname.startsWith('/pipe/')) return null;
40-
const streamUrl = pipeUrl.href.replace('/pipe/', '/stream/');
41-
return fetch(streamUrl, { method: request.method, headers: { 'x-middleware' : middlewareSig }, redirect: 'manual' });
30+
});
31+
32+
const allowedMethods = ['HEAD', 'GET', 'POST', 'PATCH', 'DELETE'];
33+
34+
const statusCodes = {
35+
400: 'Bad Request',
36+
405: 'Method Not Allowed',
37+
417: 'Expectation Failed',
38+
429: 'Too Many Requests'
39+
};
40+
41+
// Returns Response object with JSON body containing error details for given statusCode
42+
// message parameter is optional
43+
function errorResponse (statusCode, message) {
44+
const statusText = statusCodes[statusCode];
45+
const supportedMethods = allowedMethods.join(',');
46+
return Response.json(
47+
{ message: message ?? statusText, error: statusText, statusCode },
48+
{
49+
status: statusCode,
50+
statusText,
51+
headers: {
52+
'Access-Control-Allow-Origin': '*',
53+
'Access-Control-Allow-Methods': supportedMethods,
54+
Allow: supportedMethods
55+
}
56+
}
57+
);
4258
}
4359

44-
export default async function middleware(request) {
45-
const fromMiddleware = request.headers.get('x-middleware') === middlewareSig;
60+
// Returns a response from the backend (serverless-function) to the piped request modified to have no body.
61+
// Prevents Fastify in the backend from parsing the original request.body unnecessarily.
62+
// May not be needed when Fastify dependency is removed from the backend.
63+
async function processPipe (request) {
64+
// No need to worry with GET/HEAD requests as Fastify at backend won't parse content for these methods
65+
if (request.method.match(/(GET|HEAD)/gi)) return next();
66+
return fetch(request.url, {
67+
method: request.method,
68+
headers: { 'x-middleware-auth': middlewareSig },
69+
redirect: 'manual'
70+
});
71+
}
72+
73+
// Performs basic validation and limiting
74+
// Returns a Response object or Promise that resolves to a Response
75+
// Calling next() actually returns a Response with added header 'x-middleware-next'
76+
// Ref: @vercel/edge source - https://www.npmjs.com/package/@vercel/edge?activeTab=code
77+
export default async function middleware (request) {
78+
// Block requests with Expect headers
79+
if (request.headers.has('expect')) return errorResponse(417, 'Expect header is not allowed');
80+
81+
// Detect a pipe request to let it have any Content-Type and Length, including `Transfer-Encoding: chunked` header
82+
const isPiped = new URL(request.url).pathname.startsWith('/pipe/');
83+
84+
// Block unallowed methods and chunked transfer if not pipe
85+
if (!isPiped) {
86+
if (allowedMethods.includes(request.method.toUpperCase()) === false) {
87+
return errorResponse(405, `Method: ${request.method}, is not allowed`);
88+
}
89+
if (request.headers.get('transfer-encoding')?.toLowerCase()?.includes('chunked')) {
90+
return errorResponse(400, 'Provide content-length header instead of chunked transfer');
91+
}
92+
}
93+
94+
const contentType = request.headers.get('content-type') ?? '';
95+
const contentLength = parseInt(request.headers.get('content-length') ?? 0);
96+
// Absent content-length is as good as 0, as Transfer-Encoding: chunked is not allowed
97+
98+
switch (true) {
99+
case isPiped:
100+
break; // Allowed to have any Content-Type and Length, including `Transfer-Encoding: chunked` header
101+
case contentLength === 0:
102+
break; // Doesn't matter what the content-type is as it wont be parsed
103+
case contentType.includes('application/json'):
104+
case contentType.includes('application/x-www-form-urlencoded'):
105+
case contentType.includes('text/plain'):
106+
case contentType.includes('text/html'):
107+
if (contentLength > bodyLimit) {
108+
return errorResponse(400, `Content-Length: ${contentLength}, is not within ${bodyLimit}`);
109+
} else {
110+
break;
111+
}
112+
default:
113+
return errorResponse(400, `Content-Type: '${contentType}', is not allowed`);
114+
}
115+
116+
// If one invocation of this middleware modifies the Request and resends,
117+
// another invocation of this middleware will intercept it before it reaches the backend.
118+
// The following flag tells the latter invocation that it need not re-process the request.
119+
const isFromMiddleware = request.headers.get('x-middleware-auth') === middlewareSig;
46120

47121
// Ratelimiting by ip is too restrictive: may block users accessing internet from the same router
48122
const ratelimitBy = [
@@ -52,21 +126,16 @@ export default async function middleware(request) {
52126
].join('@');
53127

54128
// For requests from middleware, no rate-limiting is necessary
55-
const { success, reset } = fromMiddleware ? { success: true, reset: 0 } : await ratelimit.limit(ratelimitBy);
129+
const { success, reset } = isFromMiddleware ? { success: true, reset: 0 } : await ratelimit.limit(ratelimitBy);
56130

57131
if (success) {
58-
const streamResponse = await pipeToStream(request);
59-
// For non-pipe requests, streamResponse is null.
60-
return streamResponse ?? next();
61-
}
62-
else {
63-
return Response.json(
64-
{ message: `Try after ${(reset - Date.now())/1000} seconds`, error: "Too Many Requests", statusCode: 429 },
65-
{
66-
status: 429,
67-
statusText: "Too Many Requests",
68-
headers: {"Access-Control-Allow-Origin":"*"}
69-
},
70-
)
132+
switch (true) {
133+
case isPiped:
134+
if (!isFromMiddleware) return processPipe(request);
135+
default:
136+
return next();
137+
}
138+
} else {
139+
return errorResponse(429, `Try after ${(reset - Date.now()) / 1000} seconds`);
71140
}
72141
}

vercel.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
],
2121
"rewrites": [
2222
{
23-
"source": "/(public|private|keys|stream)/:path*",
23+
"source": "/(public|private|keys|pipe)/:path*",
2424
"destination": "/api"
2525
},
2626
{

0 commit comments

Comments
 (0)