├── bun.lockb
├── .prettierrc
├── examples
├── templates
│ └── test.html
├── README.md
├── index.js
├── local.js
├── webpack.config.js
├── package.json
├── serverless.yml
├── scripts
│ └── publish.js
└── handler.js
├── src
├── services
│ ├── cache.ts
│ └── kv-storage.ts
├── encryption
│ ├── hash.ts
│ └── aes.ts
├── utils.ts
├── constants.ts
├── loggers
│ ├── flatten.ts
│ ├── http.ts
│ ├── kinesis.ts
│ └── chunker.ts
├── handlers
│ ├── split.ts
│ ├── response.ts
│ ├── jwt-refresh.ts
│ ├── lambda.ts
│ ├── index.ts
│ ├── basic-auth.ts
│ ├── signature.ts
│ ├── origin.ts
│ ├── rate-limit.ts
│ ├── kv-storage.ts
│ ├── transform.ts
│ ├── loadbalancer.ts
│ ├── kv-storage-binding.ts
│ ├── logger.ts
│ ├── cors.ts
│ ├── s3.ts
│ ├── jwt.ts
│ ├── cache.ts
│ ├── geo-decorator.ts
│ └── oauth2.ts
└── index.ts
├── integration
├── helloworld.js
├── server.ts
└── run.js
├── test
├── handlers
│ ├── basic-auth.test.ts
│ ├── loadbalancer.test.ts
│ ├── response.test.ts
│ ├── ratelimit.test.ts
│ ├── kv-storage.test.ts
│ ├── transformer.test.ts
│ ├── s3.test.ts
│ ├── cors.test.ts
│ └── oauth2.test.ts
├── encryption
│ ├── aes.test.ts
│ └── hmac.test.ts
├── loggers
│ ├── http.test.ts
│ └── chunker.test.ts
└── helpers.ts
├── .vscode
└── launch.json
├── .github
└── workflows
│ ├── release.yml
│ └── pull-request.yml
├── tsconfig.json
├── .eslintrc.json
├── LICENSE
├── .gitignore
├── package.json
└── README.md
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/markusahlstrand/cloudworker-proxy/HEAD/bun.lockb
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "arrowParens": "always"
6 | }
7 |
--------------------------------------------------------------------------------
/examples/templates/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Hello 4!
4 |
5 | This is a static file served from the KV-Storage.
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | This example shows how to package and deploy a worker using the cloudworker-proxy and serverless.
2 |
3 | Add a .env file in the examples folder with the cloudflare account info as follows:
4 | ```
5 | CLOUDFLARE_AUTH_KEY=
6 | CLOUDFLARE_AUTH_EMAIL=
7 | CLOUDFLARE_ACCOUNT_ID=
8 | CLOUDFLARE_ZONE_ID=
9 | ```
--------------------------------------------------------------------------------
/src/services/cache.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-undef
2 | const cache = caches.default;
3 |
4 | export async function get(req) {
5 | const cachedResponse = await cache.match(req);
6 |
7 | return cachedResponse;
8 | }
9 |
10 | export async function set(req, res) {
11 | return cache.put(req.href, res);
12 | }
13 |
14 | export default {
15 | get,
16 | set,
17 | };
18 |
--------------------------------------------------------------------------------
/src/encryption/hash.ts:
--------------------------------------------------------------------------------
1 | async function hash(data) {
2 | const encodedData = new TextEncoder().encode(data);
3 |
4 | const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData);
5 | const hashArray = Array.from(new Uint8Array(hashBuffer));
6 | const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
7 | return hashHex;
8 | }
9 |
10 | export default hash;
11 |
--------------------------------------------------------------------------------
/integration/helloworld.js:
--------------------------------------------------------------------------------
1 | const Proxy = require('../dist/index.js');
2 | const config = [
3 | {
4 | handlerName: 'response',
5 | options: {
6 | body: 'Hello world',
7 | },
8 | },
9 | ];
10 |
11 | const proxy = new Proxy(config);
12 |
13 | async function fetchAndApply(event) {
14 | return await proxy.resolve(event);
15 | }
16 |
17 | addEventListener('fetch', (event) => {
18 | event.respondWith(fetchAndApply(event));
19 | });
20 |
--------------------------------------------------------------------------------
/examples/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This the entrypoint for the cloudflare workers
3 | */
4 |
5 | const handler = require('./handler');
6 |
7 | async function fetchAndApply(event) {
8 | try {
9 | return await handler(event);
10 | } catch (err) {
11 | return new Response(err.message);
12 | }
13 | }
14 |
15 | // eslint-disable-next-line no-undef,no-restricted-globals
16 | addEventListener('fetch', (event) => {
17 | event.respondWith(fetchAndApply(event));
18 | });
19 |
--------------------------------------------------------------------------------
/test/handlers/basic-auth.test.ts:
--------------------------------------------------------------------------------
1 | import basicAuthFactory from '../../src/handlers/basic-auth';
2 | import helpers from '../helpers';
3 |
4 | describe('basicAuth', () => {
5 | it('should return a 401 if the basic auth headers are not available', async () => {
6 | const handler = basicAuthFactory({
7 | users: [],
8 | });
9 |
10 | const ctx = helpers.getCtx();
11 | ctx.request.path = '/test';
12 | await handler(ctx, []);
13 |
14 | expect(ctx.status).toBe(401);
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export function resolveParams(url, params = {}) {
2 | return Object.keys(params).reduce((acc, key) => acc.replace(`{${key}}`, params[key]), url);
3 | }
4 |
5 | export function instanceToJson(instance): object {
6 | return [...instance].reduce((obj, item) => {
7 | const prop = {};
8 | // eslint-disable-next-line prefer-destructuring
9 | prop[item[0]] = item[1];
10 | return { ...obj, ...prop };
11 | }, {});
12 | }
13 |
14 | export default {
15 | resolveParams,
16 | instanceToJson,
17 | };
18 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | methodsMethodsWithBody: ['POST', 'PUT', 'PATCH'],
3 | http: {
4 | statusMessages: {
5 | // eslint-disable-next-line
6 | 404: 'Not Found',
7 | },
8 | },
9 | mime: {
10 | css: 'text/css',
11 | csv: 'text/csv',
12 | html: 'text/html',
13 | ico: 'image/microsoft.vnd.icon',
14 | jpeg: 'image/jpeg',
15 | js: 'application/javascript',
16 | json: 'application/json',
17 | png: 'image/png',
18 | svg: 'image/svg+xml',
19 | xml: 'application/xml',
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/src/loggers/flatten.ts:
--------------------------------------------------------------------------------
1 | function flatten(obj, delimiter = '.', path = '') {
2 | if (!(obj instanceof Object)) {
3 | // Remove the last delimiter
4 | if (path.endsWith(delimiter)) {
5 | return { [path.slice(0, path.length - 1)]: obj };
6 | }
7 | return { [path]: obj };
8 | }
9 |
10 | return Object.keys(obj).reduce((output, key) => {
11 | if (obj[key] == null) {
12 | return output;
13 | }
14 |
15 | return { ...output, ...flatten(obj[key], delimiter, path + key + delimiter) };
16 | }, {});
17 | }
18 |
19 | export default flatten;
20 |
--------------------------------------------------------------------------------
/src/handlers/split.ts:
--------------------------------------------------------------------------------
1 | export default function splitHandler({ host }) {
2 | if (!host) {
3 | throw new Error('Need to specify a host for the split middleware.');
4 | }
5 |
6 | return async (ctx, next) => {
7 | const duplicateContext = ctx.clone();
8 | duplicateContext.cloned = true;
9 |
10 | duplicateContext.request = {
11 | ...duplicateContext.request,
12 | href: duplicateContext.request.href.replace(duplicateContext.request.href, host),
13 | host,
14 | };
15 |
16 | ctx.event.waitUntil(next(duplicateContext));
17 | await next(ctx);
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/test/encryption/aes.test.ts:
--------------------------------------------------------------------------------
1 | import aes from '../../src/encryption/aes';
2 |
3 | describe('aes', () => {
4 | it('should encrypt and decrypt back using a pbkfs2 key', async () => {
5 | const seed = 'seed';
6 |
7 | const salt = await aes.getSalt();
8 |
9 | const encodeKey = await aes.deriveAesGcmKey(seed, salt);
10 | const decodeKey = await aes.deriveAesGcmKey(seed, salt);
11 |
12 | const message = 'message';
13 | const encrypted = await aes.encrypt(encodeKey, message);
14 | const decrypted = await aes.decrypt(decodeKey, encrypted);
15 |
16 | expect(decrypted).toBe(message);
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Launch Program",
11 | "program": "${workspaceFolder}/examples/local.js"
12 | },
13 | {
14 | "type": "node",
15 | "request": "launch",
16 | "name": "Publish templates",
17 | "skipFiles": ["/**"],
18 | "program": "${workspaceFolder}/examples/scripts/publish.js"
19 | }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/examples/local.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This is the entry point for running the proxy locally using the node-cloudworker lib.
3 | */
4 | // eslint-disable-next-line
5 | require('dotenv').config({ path: '.env' });
6 | // eslint-disable-next-line
7 | const ncw = require('node-cloudworker');
8 |
9 | ncw.applyShims({
10 | kv: {
11 | accountId: process.env.KV_ACCOUNT_ID,
12 | authEmail: process.env.KV_AUTH_EMAIL,
13 | authKey: process.env.KV_AUTH_KEY,
14 | bindings: [
15 | {
16 | variable: 'TEST_NAMESPACE',
17 | namespace: process.env.KV_NAMESPACE_TEST,
18 | },
19 | ],
20 | },
21 | });
22 |
23 | const handler = require('./handler');
24 |
25 | ncw.start(handler);
26 |
--------------------------------------------------------------------------------
/examples/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const parsedEnv = require('dotenv').config({ path: '../.env' }).parsed;
4 |
5 | const envJson = {};
6 |
7 | Object.keys(parsedEnv).forEach((key) => {
8 | envJson[key] = JSON.stringify(parsedEnv[key]);
9 | });
10 |
11 | module.exports = () => ({
12 | entry: {
13 | 'bundle.js': [path.resolve(__dirname, './index.js')],
14 | },
15 | output: {
16 | filename: '[name]',
17 | path: path.resolve(__dirname, './dist'),
18 | },
19 | node: {
20 | Buffer: false,
21 | },
22 | plugins: [
23 | new webpack.DefinePlugin({
24 | 'process.env': envJson,
25 | }),
26 | ],
27 | });
28 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: bun.js CI
2 |
3 | on:
4 | push:
5 | branches: [master]
6 |
7 | jobs:
8 | release:
9 | runs-on: ubuntu-latest
10 |
11 | strategy:
12 | matrix:
13 | bun-version: [1.0.0]
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: oven-sh/setup-bun@v1
18 | with:
19 | bun-version: ${{ matrix.bun-version }}
20 | - run: bun install
21 | - run: bun test
22 | - run: bun run build
23 | - run: bun run test:integration
24 | - run: bun run lint
25 | - name: semantic-releases
26 | env:
27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 | run: bun run semantic-release
29 |
--------------------------------------------------------------------------------
/src/handlers/response.ts:
--------------------------------------------------------------------------------
1 | import utils from '../utils';
2 |
3 | export default function responseHandler({
4 | body = '',
5 | headers = {},
6 | status = 200,
7 | }: {
8 | body?: string | Record;
9 | headers?: Record;
10 | status?: number;
11 | }) {
12 | return async (ctx) => {
13 | if (body instanceof Object) {
14 | ctx.body = JSON.stringify(body);
15 | ctx.set('Content-Type', 'application/json');
16 | } else {
17 | ctx.body = utils.resolveParams(body, ctx.params);
18 | }
19 |
20 | ctx.status = status;
21 |
22 | Object.keys(headers).forEach((key) => {
23 | ctx.set(key, utils.resolveParams(headers[key], ctx.params));
24 | });
25 | };
26 | }
27 |
--------------------------------------------------------------------------------
/src/loggers/http.ts:
--------------------------------------------------------------------------------
1 | import Chunker from './chunker';
2 | import flatten from './flatten';
3 |
4 | export default class HttpLogger {
5 | constructor(options) {
6 | this.url = options.url;
7 | this.contentType = options.contentType;
8 | this.delimiter = options.delimiter;
9 | this.chunker = new Chunker({ sink: this.sendMessage.bind(this), ...options });
10 | }
11 |
12 | async log(message) {
13 | const flatMessage = flatten(message, this.delimiter);
14 |
15 | await this.chunker.push(JSON.stringify(flatMessage));
16 | }
17 |
18 | async sendMessage(data) {
19 | return fetch(this.url, {
20 | body: data,
21 | method: 'POST',
22 | headers: {
23 | 'Content-Type': this.contentType,
24 | },
25 | });
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/test/loggers/http.test.ts:
--------------------------------------------------------------------------------
1 | import HttpLogger from '../../src/loggers/http';
2 |
3 | describe('httpLogger', () => {
4 | let realFetch, fetchCalls;
5 | const mockFetch = async (url, options) => {
6 | fetchCalls.push({
7 | url,
8 | options,
9 | });
10 | };
11 |
12 | beforeEach(() => {
13 | realFetch = global.fetch;
14 | fetchCalls = [];
15 |
16 | global.fetch = mockFetch;
17 | });
18 |
19 | afterEach(() => {
20 | global.fetch = realFetch;
21 | });
22 |
23 | it('should send a message to a http endpoint', async () => {
24 | const logger = new HttpLogger({
25 | ctx: {},
26 | maxSize: 0,
27 | });
28 |
29 | logger.log({
30 | foo: 'bar',
31 | });
32 |
33 | expect(fetchCalls.length).toBe(1);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import Router from 'cloudworker-router';
2 | import defaultHandlers from './handlers';
3 |
4 | interface Rule {
5 | path: string;
6 | method: string;
7 | handlerName: string;
8 | options?: object;
9 | }
10 |
11 | module.exports = class Proxy {
12 | router: Router;
13 |
14 | constructor(rules: Rule[] = [], handlers = {}) {
15 | this.router = new Router();
16 |
17 | rules.forEach((rule) => {
18 | const handler = handlers[rule.handlerName] || defaultHandlers[rule.handlerName];
19 |
20 | if (!handler) {
21 | throw new Error(`Handler ${rule.handlerName} is not supported`);
22 | }
23 |
24 | this.router.add(rule, handler(rule.options));
25 | });
26 | }
27 |
28 | async resolve(event) {
29 | return this.router.resolve(event);
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cloudworker-proxy-examples",
3 | "version": "1.0.0",
4 | "description": "A example of how to use and deploy the cloudworker-proxy",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node local.js",
8 | "build": "webpack --mode production --config ./webpack.config.js",
9 | "deploy": "serverless deploy",
10 | "publish-kv": "node scripts/publish.js"
11 | },
12 | "author": "",
13 | "license": "MIT",
14 | "devDependencies": {
15 | "dotenv": "8.2.0",
16 | "formdata-node": "2.2.1",
17 | "node-cloudworker": "1.2.0",
18 | "serverless": "1.74.1",
19 | "serverless-cloudflare-workers": "1.2.0",
20 | "serverless-dotenv-plugin": "2.4.2",
21 | "serverless-scriptable-plugin": "1.0.5",
22 | "webpack": "4.43.0",
23 | "webpack-cli": "3.3.12"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/.github/workflows/pull-request.yml:
--------------------------------------------------------------------------------
1 |
2 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
3 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
4 |
5 | name: Unit tests
6 |
7 | on:
8 | pull_request
9 |
10 | jobs:
11 | pull-request:
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | bun-version: [1.0.0]
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 | - uses: oven-sh/setup-bun@v1
21 | with:
22 | bun-version: ${{ matrix.bun-version }}
23 | - run: bun install
24 | - run: bun test
25 | - run: bun run build
26 | - run: bun run test:integration
27 | - run: bun run lint
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["es2020", "DOM"],
4 | "module": "ESNext",
5 | "target": "es2020",
6 | "strict": true,
7 | "resolveJsonModule": true,
8 | "experimentalDecorators": true,
9 | // The following three options are set for
10 | "allowJs": true,
11 | "checkJs": false,
12 | "noImplicitAny": false,
13 | "esModuleInterop": true,
14 | "skipLibCheck": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "moduleResolution": "node",
17 | "sourceMap": true,
18 | "declaration": true,
19 | "outDir": "dist",
20 | "baseUrl": ".",
21 | "types": ["@cloudflare/workers-types", "@types/jest", "@types/service-worker-mock"],
22 | "paths": {
23 | "*": ["node_modules/*"]
24 | }
25 | },
26 | "include": ["src/**/*", "test/**/*", "integration/**/*"]
27 | }
28 |
--------------------------------------------------------------------------------
/src/handlers/jwt-refresh.ts:
--------------------------------------------------------------------------------
1 | export default async function refreshAccessToken({
2 | // eslint-disable-next-line camelcase
3 | refresh_token,
4 | authDomain,
5 | clientId,
6 | clientSecret,
7 | }) {
8 | const tokenUrl = `${authDomain}/oauth/token`;
9 |
10 | const response = await fetch(tokenUrl, {
11 | method: 'POST',
12 | headers: {
13 | 'content-type': 'application/json',
14 | },
15 | body: JSON.stringify({
16 | grant_type: 'refresh_token',
17 | client_id: clientId,
18 | client_secret: clientSecret,
19 | refresh_token,
20 | }),
21 | });
22 |
23 | if (!response.ok) {
24 | throw new Error('Authentication failed');
25 | }
26 |
27 | const body = await response.json();
28 |
29 | return {
30 | ...body,
31 | expires: Date.now() + body.expires_in * 1000,
32 | refresh_token,
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/handlers/lambda.ts:
--------------------------------------------------------------------------------
1 | import { AwsClient } from 'aws4fetch';
2 | import utils from '../utils';
3 |
4 | export default function lambdaHandlerFactory({ accessKeyId, secretAccessKey, region, lambdaName }) {
5 | const aws = new AwsClient({
6 | accessKeyId,
7 | secretAccessKey,
8 | });
9 |
10 | return async (ctx) => {
11 | const url = `https://lambda.${region}.amazonaws.com/2015-03-31/functions/${lambdaName}/invocations`;
12 |
13 | // TODO: Guess we should pass the body here?
14 | const event = {};
15 |
16 | const response = await aws.fetch(url, { body: JSON.stringify(event) });
17 |
18 | ctx.status = response.status;
19 | ctx.body = response.body;
20 | const responseHeaders = utils.instanceToJson(response.headers);
21 | Object.keys(responseHeaders).forEach((key) => {
22 | ctx.set(key, responseHeaders[key]);
23 | });
24 | };
25 | }
26 |
--------------------------------------------------------------------------------
/integration/server.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import * as wrangler from 'wrangler';
3 | import * as fs from 'fs';
4 | import * as path from 'path';
5 |
6 | function deleteFolderSync(dirPath: string): void {
7 | if (fs.existsSync(dirPath)) {
8 | fs.readdirSync(dirPath).forEach((file: string) => {
9 | const curPath = path.join(dirPath, file);
10 | if (fs.lstatSync(curPath).isDirectory()) {
11 | // Recursively delete contents
12 | deleteFolderSync(curPath);
13 | } else {
14 | // Delete file
15 | fs.unlinkSync(curPath);
16 | }
17 | });
18 | fs.rmdirSync(dirPath);
19 | }
20 | }
21 |
22 | export default async function start() {
23 | deleteFolderSync('.wrangler');
24 |
25 | return wrangler.unstable_dev('src/server.ts', {
26 | persist: false,
27 | experimental: {
28 | disableExperimentalWarning: true,
29 | },
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/integration/run.js:
--------------------------------------------------------------------------------
1 | // Basic integration test, check we can deploy our binary to cloudflare and
2 | // serve traffic
3 | // Bun will not work with wrangler until https://github.com/oven-sh/bun/issues/808
4 | // So this script has to run with node.js.
5 | // Thus we don;t have bun's testing librarys so for now we will use an exit code
6 | // the indicate failure
7 | const wrangler = require('wrangler');
8 |
9 | const fail = (reason) => {
10 | console.error(reason);
11 | process.exit(1);
12 | };
13 |
14 | async function test() {
15 | const worker = await wrangler.unstable_dev('integration/helloworld.js', {});
16 |
17 | const res = await worker.fetch('');
18 | const response = await res.text();
19 | worker.stop();
20 |
21 | if (res.status !== 200) {
22 | fail(`Unexpected status ${res.status}`);
23 | }
24 | if (response !== 'Hello world') {
25 | fail(`Unexpected response ${response}`);
26 | }
27 |
28 | console.log('Tests pass');
29 | }
30 |
31 | test();
32 |
--------------------------------------------------------------------------------
/src/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import basicAuth from './basic-auth';
2 | import cache from './cache';
3 | import cors from './cors';
4 | import geoDecorator from './geo-decorator';
5 | import jwt from './jwt';
6 | import kvStorage from './kv-storage';
7 | import kvStorageBinding from './kv-storage-binding';
8 | import lambda from './lambda';
9 | import loadbalancer from './loadbalancer';
10 | import logger from './logger';
11 | import oauth2 from './oauth2';
12 | import origin from './origin';
13 | import response from './response';
14 | import rateLimit from './rate-limit';
15 | import s3 from './s3';
16 | import signature from './signature';
17 | import split from './split';
18 | import transform from './transform';
19 |
20 | export default {
21 | basicAuth,
22 | cache,
23 | cors,
24 | geoDecorator,
25 | jwt,
26 | kvStorage,
27 | kvStorageBinding,
28 | lambda,
29 | loadbalancer,
30 | logger,
31 | oauth2,
32 | origin,
33 | rateLimit,
34 | response,
35 | s3,
36 | signature,
37 | split,
38 | transform,
39 | };
40 |
--------------------------------------------------------------------------------
/examples/serverless.yml:
--------------------------------------------------------------------------------
1 | # serverless.yml
2 | service:
3 | name: cloudworker-proxy
4 |
5 | provider:
6 | name: cloudflare
7 | environment:
8 | config:
9 | accountId: ${env:CLOUDFLARE_ACCOUNT_ID}
10 | zoneId: ${env:CLOUDFLARE_ZONE_ID}
11 |
12 | plugins:
13 | - serverless-dotenv-plugin
14 | - serverless-scriptable-plugin
15 | - serverless-cloudflare-workers
16 |
17 | custom:
18 | scriptHooks:
19 | package:createDeploymentArtifacts: npm run build
20 | dotenv:
21 | path: ../.env
22 |
23 | functions:
24 | cloudworker-proxy-examples:
25 | # What the script will be called on Cloudflare (this property value must match the function name one line above)
26 | name: cloudworker-proxy-examples
27 | # The name of the script on your machine, omitting the .js file extension
28 | script: 'dist/bundle'
29 | webpack: false
30 | resources:
31 | kv:
32 | - variable: TEST_NAMESPACE
33 | namespace: test
34 | events:
35 | - http:
36 | url: 'proxy.cloudproxy.io/*'
37 | method: ANY
38 |
--------------------------------------------------------------------------------
/test/handlers/loadbalancer.test.ts:
--------------------------------------------------------------------------------
1 | import loadbalancerFactory from '../../src/handlers/loadbalancer';
2 | import helpers from '../helpers';
3 |
4 | describe('loadbalancer', () => {
5 | let fetch;
6 | let fetchedUrl;
7 |
8 | beforeEach(() => {
9 | fetch = global.fetch;
10 | global.fetch = async (url, options) => {
11 | fetchedUrl = url;
12 |
13 | return new Response('test', {
14 | status: 200,
15 | });
16 | };
17 | });
18 |
19 | afterEach(() => {
20 | global.fetch = fetch;
21 | delete global.fetch;
22 | delete global.caches;
23 | });
24 |
25 | it('should make a request to source', async () => {
26 | const handler = loadbalancerFactory({
27 | sources: [
28 | {
29 | url: 'https://example.com/{file}',
30 | },
31 | ],
32 | });
33 |
34 | const ctx = helpers.getCtx();
35 | ctx.params = {
36 | file: 'test',
37 | };
38 | ctx.request.search = '?foo=bar';
39 |
40 | await handler(ctx, []);
41 |
42 | expect(fetchedUrl).toBe('https://example.com/test?foo=bar');
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "airbnb-base",
4 | "plugin:prettier/recommended",
5 | "plugin:@typescript-eslint/eslint-recommended",
6 | "plugin:@typescript-eslint/recommended"
7 | ],
8 | "parser": "@typescript-eslint/parser",
9 | "plugins": [
10 | "@typescript-eslint"
11 | ],
12 | "globals": {
13 | "atob": "readonly",
14 | "btoa": "readonly",
15 | "crypto": "readonly",
16 | "fetch": "readonly",
17 | "FormData": "readonly",
18 | "Request": "readonly",
19 | "Response": "readonly",
20 | "TextDecoder": "readonly",
21 | "TextEncoder": "readonly"
22 | },
23 | "rules": {
24 | "no-use-before-define": ["error", { "functions": false }],
25 | "comma-dangle": ["error", "always-multiline"],
26 | "arrow-parens": 0,
27 | "import/extensions": [
28 | "error",
29 | "ignorePackages",
30 | {
31 | "ts": "never"
32 | }
33 | ]
34 | },
35 | "settings": {
36 | "import/resolver": {
37 | "node": {
38 | "extensions": [
39 | ".js",
40 | ".ts"
41 | ]
42 | }
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Markus Ahlstrand
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/examples/scripts/publish.js:
--------------------------------------------------------------------------------
1 | // require('dotenv').config({ path: '../.env' });
2 | require('dotenv').config({ path: '.env' });
3 | const fs = require('fs');
4 | const ncw = require('node-cloudworker');
5 | const crypto = require('crypto');
6 |
7 | ncw.applyShims();
8 |
9 | const KvStorage = require('../../src/services/kv-storage');
10 |
11 | const kvStorage = new KvStorage({
12 | accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
13 | namespace: process.env.KV_NAMESPACE_TEST,
14 | authEmail: process.env.CLOUDFLARE_AUTH_EMAIL,
15 | authKey: process.env.CLOUDFLARE_AUTH_KEY,
16 | ttl: null,
17 | });
18 |
19 | console.log('start');
20 |
21 | const data = fs.readFileSync('examples/templates/test.html', 'utf8');
22 | const buffer = Buffer.from(data);
23 | const etag = `W/${crypto.createHash('md5').update(data).digest('hex')}`;
24 | kvStorage
25 | .put('test.html', buffer, {
26 | headers: {
27 | etag,
28 | 'content-type': 'text/html',
29 | 'x-content-length': buffer.length,
30 | 'content-length': buffer.length,
31 | },
32 | })
33 | .then(() => {
34 | console.log('Done');
35 | })
36 | .catch((err) => {
37 | console.log('Failed: ' + err.message);
38 | });
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 |
63 | # Serverless stuff
64 | dist
65 | .serverless
--------------------------------------------------------------------------------
/src/handlers/basic-auth.ts:
--------------------------------------------------------------------------------
1 | import get from 'lodash.get';
2 |
3 | const _ = {
4 | get,
5 | };
6 |
7 | function setUnauthorizedResponse(ctx) {
8 | ctx.status = 401;
9 | ctx.body = 'Unauthorized';
10 | ctx.set('WWW-Authenticate', 'Basic');
11 | }
12 |
13 | /**
14 | * Applies authentication on the request
15 | * @param {*} ctx
16 | * @param {*} next
17 | */
18 | export default function basicAuth(options) {
19 | return async (ctx, next) => {
20 | // Forces a new login which is the closest you can get to a logout with basic auth
21 | if (ctx.request.path === options.logoutPath) {
22 | return setUnauthorizedResponse(ctx);
23 | }
24 |
25 | const authHeaders = _.get(ctx, 'request.headers.authorization');
26 | if (!authHeaders || !authHeaders.startsWith('Basic ')) {
27 | return setUnauthorizedResponse(ctx);
28 | }
29 |
30 | const userTokens = options.users.map((user) => user.authToken);
31 |
32 | const authToken = authHeaders.substring(6);
33 | const userIndex = userTokens.indexOf(authToken);
34 | if (userIndex === -1) {
35 | return setUnauthorizedResponse(ctx);
36 | }
37 |
38 | ctx.state.user = options.users[userIndex].username;
39 |
40 | return next(ctx);
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/test/handlers/response.test.ts:
--------------------------------------------------------------------------------
1 | import responseFactory from '../../src/handlers/response';
2 | import helpers from '../helpers';
3 |
4 | describe('response', () => {
5 | it('should return a static response', async () => {
6 | const responseHandler = responseFactory({
7 | status: 200,
8 | body: 'Test',
9 | headers: {
10 | foo: 'bar',
11 | },
12 | });
13 |
14 | const ctx = helpers.getCtx();
15 |
16 | await responseHandler(ctx, []);
17 |
18 | expect(ctx.body).toBe('Test');
19 | expect(ctx.status).toBe(200);
20 | expect(ctx.response.headers.get('foo')).toBe('bar');
21 | });
22 |
23 | it('should return a json body + headers if the body is an object', async () => {
24 | const responseHandler = responseFactory({
25 | status: 200,
26 | body: {
27 | foo: 'bar',
28 | },
29 | headers: {
30 | foo: 'bar',
31 | },
32 | });
33 |
34 | const ctx = helpers.getCtx();
35 |
36 | await responseHandler(ctx, []);
37 |
38 | expect(ctx.body).toBe('{"foo":"bar"}');
39 | expect(ctx.status).toBe(200);
40 | expect(ctx.response.headers.get('foo')).toBe('bar');
41 | expect(ctx.response.headers.get('Content-Type')).toBe('application/json');
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/src/handlers/signature.ts:
--------------------------------------------------------------------------------
1 | let keyCache;
2 |
3 | function str2ab(str) {
4 | const uintArray = new Uint8Array(
5 | str.split('').map((char) => {
6 | return char.charCodeAt(0);
7 | }),
8 | );
9 | return uintArray;
10 | }
11 |
12 | async function getKey(secret) {
13 | if (!keyCache) {
14 | keyCache = await crypto.subtle.importKey(
15 | 'raw',
16 | str2ab(secret),
17 | { name: 'HMAC', hash: { name: 'SHA-256' } },
18 | false,
19 | ['sign', 'verify'],
20 | );
21 | }
22 | return keyCache;
23 | }
24 |
25 | async function sign(path, secret) {
26 | const key = await getKey(secret);
27 |
28 | const sig = await crypto.subtle.sign({ name: 'HMAC' }, key, str2ab(path));
29 | return btoa(String.fromCharCode.apply(null, new Uint8Array(sig)))
30 | .replace(/\+/g, '-')
31 | .replace(/\//g, '_')
32 | .replace(/=/g, '');
33 | }
34 |
35 | export default function signatureHandler({ secret }) {
36 | return async (ctx, next) => {
37 | const pathWithQuery = (ctx.request.path + ctx.request.search).replace(
38 | /([?|&]sign=[\w|-]+)/,
39 | '',
40 | );
41 |
42 | const signature = await sign(pathWithQuery, secret);
43 |
44 | if (signature !== ctx.query.sign) {
45 | ctx.status = 403;
46 | return;
47 | }
48 |
49 | await next(ctx);
50 | };
51 | }
52 |
--------------------------------------------------------------------------------
/src/loggers/kinesis.ts:
--------------------------------------------------------------------------------
1 | import { AwsClient } from 'aws4fetch';
2 | import Chunker from './chunker';
3 | import flatten from './flatten';
4 |
5 | export default class KinesisLogger {
6 | constructor(options) {
7 | this.delimiter = options.delimiter;
8 | this.chunker = new Chunker({ sink: this.sendMessage.bind(this), ...options });
9 | this.awsClient = new AwsClient({
10 | accessKeyId: options.accessKeyId,
11 | secretAccessKey: options.secretAccessKey,
12 | region: options.region,
13 | });
14 | this.streamName = options.streamName;
15 | this.region = options.region;
16 | }
17 |
18 | async log(message) {
19 | const flatMessage = flatten(message, this.delimiter);
20 |
21 | await this.chunker.push(JSON.stringify(flatMessage));
22 | }
23 |
24 | async sendMessage(message) {
25 | const data = btoa(`${JSON.stringify(message)}\n`);
26 | const body = JSON.stringify({
27 | DeliveryStreamName: this.streamName,
28 | Record: {
29 | Data: data,
30 | },
31 | });
32 |
33 | const url = `https://firehose.${this.region}.amazonaws.com`;
34 | const request = new Request(url, {
35 | method: 'POST',
36 | body,
37 | headers: {
38 | 'X-Amz-Target': 'Firehose_20150804.PutRecord',
39 | 'Content-Type': ' application/x-amz-json-1.1',
40 | },
41 | });
42 |
43 | return this.awsClient.fetch(request);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/handlers/origin.ts:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash.get';
2 | import constants from '../constants';
3 | import utils from '../utils';
4 |
5 | const _ = {
6 | get: lodashGet,
7 | };
8 |
9 | function filterCfHeaders(headers) {
10 | const result = {};
11 |
12 | Object.keys(headers).forEach((key) => {
13 | if (!key.startsWith('cf')) {
14 | result[key] = headers[key];
15 | }
16 | });
17 |
18 | return result;
19 | }
20 |
21 | export default function originHandler(options) {
22 | const { localOriginOverride } = options;
23 |
24 | return async (ctx) => {
25 | const url = process.env.LOCAL
26 | ? `${localOriginOverride || ctx.request.origin}${ctx.request.path}`
27 | : ctx.request.href;
28 |
29 | const requestOptions = {
30 | headers: filterCfHeaders(ctx.request.headers),
31 | method: ctx.request.method,
32 | redirect: 'manual',
33 | };
34 |
35 | if (
36 | constants.methodsMethodsWithBody.indexOf(ctx.request.method) !== -1 &&
37 | _.get(ctx, 'event.request.body')
38 | ) {
39 | const clonedRequest = ctx.event.request.clone();
40 | requestOptions.body = clonedRequest.body;
41 | }
42 |
43 | const response = await fetch(url, requestOptions);
44 |
45 | ctx.body = response.body;
46 | ctx.status = response.status;
47 | const responseHeaders = utils.instanceToJson(response.headers);
48 | Object.keys(responseHeaders).forEach((key) => {
49 | ctx.set(key, responseHeaders[key]);
50 | });
51 | };
52 | }
53 |
--------------------------------------------------------------------------------
/src/handlers/rate-limit.ts:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash.get';
2 | import lodashSet from 'lodash.set';
3 |
4 | const _ = {
5 | get: lodashGet,
6 | set: lodashSet,
7 | };
8 |
9 | export default function rateLimitHandler({ type = 'IP', scope = 'default', limit = 1000 }) {
10 | const buckets = {};
11 |
12 | function getKey(currentMinute, headers) {
13 | const ip = headers['x-real-ip'];
14 |
15 | if (type === 'IP') {
16 | return `minute.${currentMinute}.${scope}.${ip}`;
17 | }
18 |
19 | return `minute.${currentMinute}.${scope}.account`;
20 | }
21 |
22 | function cleanUp(currentMinute) {
23 | const minutes = _.get(buckets, 'minutes', {});
24 | Object.keys(minutes).forEach((minute) => {
25 | if (minute !== currentMinute) {
26 | delete buckets.minutes.minute;
27 | }
28 | });
29 | }
30 |
31 | return async (ctx, next) => {
32 | const currentMinute = Math.trunc(Date.now() / (1000 * 60));
33 | const reset = Math.trunc(currentMinute * 60 + 60 - Date.now() / 1000);
34 |
35 | const key = getKey(currentMinute, ctx.request.headers);
36 |
37 | let count = _.get(buckets, key, 0);
38 |
39 | // Don't count head and options reqests
40 | if (['HEAD', 'OPTIONS'].indexOf(ctx.request.method) === -1) {
41 | count += 1;
42 | }
43 |
44 | ctx.set('X-Ratelimit-Limit', limit);
45 | ctx.set('X-Ratelimit-Count', count);
46 | ctx.set('X-Ratelimit-Reset', reset);
47 |
48 | _.set(buckets, key, count);
49 |
50 | if (limit < count) {
51 | ctx.status = 429;
52 | return;
53 | }
54 |
55 | cleanUp(currentMinute);
56 |
57 | await next(ctx);
58 | };
59 | }
60 |
--------------------------------------------------------------------------------
/test/helpers.ts:
--------------------------------------------------------------------------------
1 | class Context {
2 | request: {
3 | method: string;
4 | path: string;
5 | query: {};
6 | hostname: string;
7 | host: string;
8 | protocol: string;
9 | headers: Record;
10 | };
11 | event: {};
12 | state: {};
13 | response: { headers: Map; body: string | undefined };
14 | body: object | string | undefined;
15 | status: number;
16 | query: any;
17 | params: Record = {};
18 |
19 | constructor() {
20 | this.request = {
21 | method: 'GET',
22 | path: '/',
23 | host: 'example.com',
24 | hostname: 'example.com',
25 | protocol: 'http',
26 | query: {},
27 | headers: {},
28 | };
29 | this.event = {};
30 | this.state = {};
31 | this.response = {
32 | headers: new Map(),
33 | body: undefined,
34 | };
35 | this.body = undefined;
36 | this.status = 404;
37 |
38 | // Shortcuts directly on the context
39 | this.query = this.request.query;
40 | }
41 |
42 | set(key: string, value: string) {
43 | this.response.headers.set(key, value);
44 | }
45 | header(key: string) {
46 | return this.response.headers.get(key);
47 | }
48 | }
49 |
50 | /**
51 | * A minimal ctx used for testing
52 | */
53 | function getCtx() {
54 | const ctx = new Context();
55 | ctx.request.headers.origin = 'localhost';
56 | return ctx;
57 | }
58 |
59 | /**
60 | * Returns an empty function that can be used to terminate routes when testing
61 | */
62 | function getNext() {
63 | return async (ctx) => {
64 | ctx.status = 200;
65 | ctx.body = 'A test helper';
66 | };
67 | }
68 |
69 | export default {
70 | getCtx,
71 | getNext,
72 | };
73 |
--------------------------------------------------------------------------------
/test/loggers/chunker.test.ts:
--------------------------------------------------------------------------------
1 | import Chunker from '../../src/loggers/chunker';
2 |
3 | describe('chunker', () => {
4 | it('should enque a message', async () => {
5 | const chunker = new Chunker({
6 | maxSeconds: 0.01,
7 | sink: async () => {},
8 | });
9 |
10 | const timerPromise = chunker.push({
11 | foo: 'bar',
12 | });
13 |
14 | expect(chunker.queue.length).toBe(1);
15 |
16 | await timerPromise;
17 | });
18 |
19 | it('should process a message once the queue length is higher than the limit', async () => {
20 | const chunker = new Chunker({
21 | maxSize: 0,
22 | sink: () => {},
23 | });
24 |
25 | chunker.push({
26 | foo: 'bar',
27 | });
28 |
29 | expect(chunker.queue.length).toBe(0);
30 | });
31 |
32 | it('should concat two messages to a single data chunk', async () => {
33 | let counter = 0;
34 |
35 | const chunker = new Chunker({
36 | maxSize: 1,
37 | sink: () => {
38 | counter++;
39 | },
40 | });
41 |
42 | await Promise.all([
43 | chunker.push({
44 | foo: 'bar',
45 | }),
46 | chunker.push({
47 | foo: 'bar',
48 | }),
49 | ]);
50 |
51 | expect(chunker.queue.length).toBe(0);
52 | expect(counter).toBe(1);
53 | });
54 |
55 | it('should send a chunk once the timeout is triggered', async () => {
56 | let counter = 0;
57 |
58 | const chunker = new Chunker({
59 | maxSize: 1,
60 | maxSeconds: 0.001, // The seconds to wait for the chunk to be sent
61 | sink: async () => {
62 | counter++;
63 | return true;
64 | },
65 | });
66 |
67 | await chunker.push({
68 | foo: 'bar',
69 | });
70 |
71 | expect(chunker.queue.length).toBe(0);
72 | expect(counter).toBe(1);
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/src/handlers/kv-storage.ts:
--------------------------------------------------------------------------------
1 | import KvStorage from '../services/kv-storage';
2 | import constants from '../constants';
3 | import utils from '../utils';
4 |
5 | function setDefaultLocation(url, defaultExtension, defaultIndexDocument) {
6 | if (url === '/' && defaultIndexDocument) {
7 | return defaultIndexDocument;
8 | }
9 |
10 | const file = url.split('/').pop();
11 | const extention = file.split('.').pop();
12 | if (extention !== file) {
13 | return url;
14 | }
15 |
16 | return `${url}.${defaultExtension}`;
17 | }
18 |
19 | export default function kvStorageHandler({
20 | kvAccountId,
21 | kvNamespace,
22 | kvAuthEmail,
23 | kvAuthKey,
24 | kvBasePath = '',
25 | kvKey = '{file}',
26 | defaultExtension = 'html',
27 | defaultIndexDocument,
28 | defaultErrorDocument,
29 | mime = {},
30 | mode = 'rest',
31 | }) {
32 | const kvStorage = new KvStorage({
33 | accountId: kvAccountId,
34 | namespace: kvNamespace,
35 | authEmail: kvAuthEmail,
36 | authKey: kvAuthKey,
37 | mode,
38 | });
39 |
40 | const mimeMappings = { ...constants.mime, ...mime };
41 |
42 | return async (ctx) => {
43 | const path = utils.resolveParams(kvKey, ctx.params);
44 |
45 | const key =
46 | path === '' && defaultIndexDocument
47 | ? defaultIndexDocument
48 | : setDefaultLocation(path, defaultExtension);
49 |
50 | let result = await kvStorage.get(kvBasePath + key);
51 |
52 | if (!result && defaultErrorDocument) {
53 | result = await kvStorage.get(kvBasePath + defaultErrorDocument);
54 | }
55 |
56 | if (result) {
57 | ctx.status = 200;
58 | ctx.body = result;
59 | ctx.set('Content-Type', mimeMappings[key.split('.').pop()] || 'text/plain');
60 | } else {
61 | ctx.status = 404;
62 | ctx.body = constants.http.statusMessages['404'];
63 | ctx.set('Content-Type', 'text/plain');
64 | }
65 | };
66 | }
67 |
--------------------------------------------------------------------------------
/test/handlers/ratelimit.test.ts:
--------------------------------------------------------------------------------
1 | import rateLimitFactory from '../../src/handlers/rate-limit';
2 | import helpers from '../helpers';
3 |
4 | describe('ratelimit', () => {
5 | it('should add ratelimit headers to the response', async () => {
6 | const rateLimit = rateLimitFactory({});
7 |
8 | const ctx = helpers.getCtx();
9 |
10 | await rateLimit(ctx, helpers.getNext());
11 |
12 | expect(ctx.response.headers.get('X-Ratelimit-Count')).toBe(1);
13 | expect(ctx.response.headers.get('X-Ratelimit-Limit')).toBe(1000);
14 | expect(ctx.response.headers.get('X-Ratelimit-Count')).toBeLessThan(60);
15 | });
16 |
17 | it('should not count options requests', async () => {
18 | const rateLimit = rateLimitFactory({});
19 |
20 | const ctx = helpers.getCtx();
21 | ctx.request.method = 'OPTIONS';
22 |
23 | await rateLimit(ctx, helpers.getNext());
24 |
25 | expect(ctx.response.headers.get('X-Ratelimit-Count')).toBe(0);
26 | expect(ctx.response.headers.get('X-Ratelimit-Limit')).toBe(1000);
27 | expect(ctx.response.headers.get('X-Ratelimit-Count')).toBeLessThan(60);
28 | });
29 |
30 | it('should not count head requests', async () => {
31 | const rateLimit = rateLimitFactory({});
32 |
33 | const ctx = helpers.getCtx();
34 | ctx.request.method = 'HEAD';
35 |
36 | await rateLimit(ctx, helpers.getNext());
37 |
38 | expect(ctx.response.headers.get('X-Ratelimit-Count')).toBe(0);
39 | expect(ctx.response.headers.get('X-Ratelimit-Limit')).toBe(1000);
40 | expect(ctx.response.headers.get('X-Ratelimit-Count')).toBeLessThan(60);
41 | });
42 |
43 | it('should return a 429 for ratelimited requests', async () => {
44 | const rateLimit = rateLimitFactory({
45 | limit: 1,
46 | });
47 |
48 | const ctx1 = helpers.getCtx();
49 | const ctx2 = helpers.getCtx();
50 |
51 | await rateLimit(ctx1, helpers.getNext());
52 | await rateLimit(ctx2, helpers.getNext());
53 |
54 | expect(ctx1.status).toBe(200);
55 | expect(ctx2.status).toBe(429);
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/test/handlers/kv-storage.test.ts:
--------------------------------------------------------------------------------
1 | import kvStorageFactory from '../../src/handlers/kv-storage';
2 | import helpers from '../helpers';
3 | import fetchMock from 'fetch-mock';
4 |
5 | function mockCall(key) {
6 | fetchMock.mock(
7 | `https://api.cloudflare.com/client/v4/accounts/accountId/storage/kv/namespaces/namespace/values/${key}`,
8 | 'OK',
9 | );
10 | }
11 |
12 | describe('kvStorage', () => {
13 | let handler;
14 |
15 | beforeEach(() => {
16 | handler = kvStorageFactory({
17 | kvAccountId: 'accountId',
18 | kvNamespace: 'namespace',
19 | kvAuthEmail: 'authEmail',
20 | kvAuthKey: 'authKey',
21 | });
22 | });
23 |
24 | afterEach(() => {
25 | fetchMock.restore();
26 | });
27 |
28 | it('should fetch a file from kv', async () => {
29 | mockCall('index.html');
30 |
31 | const ctx = helpers.getCtx();
32 | ctx.request.path = '/index.html';
33 | ctx.params = {
34 | file: 'index.html',
35 | };
36 | await handler(ctx, []);
37 |
38 | expect(ctx.status).toBe(200);
39 | });
40 |
41 | it('should return a 404 if a file is not found', async () => {
42 | const ctx = helpers.getCtx();
43 | ctx.request.path = '/index.html';
44 | ctx.params = {
45 | file: 'index.html',
46 | };
47 | await handler(ctx, []);
48 |
49 | expect(ctx.status).toBe(404);
50 | });
51 |
52 | it('apply a default file type to a file fetched for kv', async () => {
53 | mockCall('index.html');
54 |
55 | const ctx = helpers.getCtx();
56 | ctx.request.path = '/index';
57 | ctx.params = {
58 | file: 'index',
59 | };
60 | await handler(ctx, []);
61 |
62 | expect(ctx.status).toBe(200);
63 | });
64 |
65 | it('apply a default file type to a file in a nested folder', async () => {
66 | mockCall('nested/folder/index.html');
67 |
68 | const ctx = helpers.getCtx();
69 | ctx.request.path = '/nested/folder/index';
70 | ctx.params = {
71 | file: 'nested/folder/index',
72 | };
73 | await handler(ctx, []);
74 |
75 | expect(ctx.status).toBe(200);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/handlers/transform.ts:
--------------------------------------------------------------------------------
1 | async function streamBody(readable, writable, regexes) {
2 | const reader = readable.getReader();
3 | const writer = writable.getWriter();
4 |
5 | const textDecoder = new TextDecoder();
6 | const textEncoder = new TextEncoder();
7 |
8 | // eslint-disable-next-line no-constant-condition
9 | while (true) {
10 | // eslint-disable-next-line no-await-in-loop
11 | const { done, value } = await reader.read();
12 |
13 | if (done) {
14 | break;
15 | }
16 |
17 | const chunk = textDecoder.decode(value);
18 | const transformedChunk = transformChunk(chunk, regexes);
19 | const encodedText = textEncoder.encode(transformedChunk);
20 |
21 | // The writer throws in cloudflare if the connection is closed
22 | // eslint-disable-next-line no-await-in-loop
23 | await writer.write(encodedText);
24 | }
25 |
26 | await writer.close();
27 | }
28 |
29 | function template(data, args) {
30 | return data.replace(/{{\$(\d)}}/g, ($0, index) => {
31 | return args[parseInt(index, 10)];
32 | });
33 | }
34 |
35 | function transformChunk(chunk, regexes) {
36 | return regexes.reduce((acc, transform) => {
37 | return acc.replace(transform.regex, (...args) => {
38 | return template(transform.replace, args);
39 | });
40 | }, chunk);
41 | }
42 |
43 | export default function transformFactory({ transforms = [], statusCodes = [200] }) {
44 | const regexes = transforms.map((transform) => {
45 | return {
46 | regex: new RegExp(transform.regex, 'g'),
47 | replace: transform.replace,
48 | };
49 | });
50 |
51 | return async (ctx, next) => {
52 | await next(ctx);
53 |
54 | const { body } = ctx;
55 |
56 | if (statusCodes.indexOf(ctx.status) === -1) {
57 | // Only tranform on matching statuscodes
58 | } else if (typeof body === 'string') {
59 | ctx.body = transformChunk(body, regexes);
60 | } else {
61 | // eslint-disable-next-line no-undef
62 | const { readable, writable } = new TransformStream();
63 | streamBody(body, writable, regexes);
64 | ctx.body = readable;
65 | }
66 | };
67 | }
68 |
--------------------------------------------------------------------------------
/test/encryption/hmac.test.ts:
--------------------------------------------------------------------------------
1 | import nodeCrypto from 'crypto';
2 |
3 | function str2ab(str) {
4 | const uintArray = new Uint8Array(
5 | str.split('').map((char) => {
6 | return char.charCodeAt(0);
7 | }),
8 | );
9 | return uintArray;
10 | }
11 |
12 | describe('hmac', () => {
13 | it('should get the same signature in node as in js', async () => {
14 | // Generate the SHA-256 hash from the secret string
15 | const key = await crypto.subtle.importKey(
16 | 'raw',
17 | str2ab('secret'),
18 | { name: 'HMAC', hash: { name: 'SHA-256' } },
19 | false,
20 | ['sign', 'verify'],
21 | );
22 |
23 | // Sign the "str" with the key generated previously
24 | const sig = await crypto.subtle.sign({ name: 'HMAC' }, key, str2ab('message'));
25 | const jsSignature = btoa(String.fromCharCode.apply(null, new Uint8Array(sig)));
26 |
27 | const nodeSignature = nodeCrypto
28 | .createHmac('SHA256', 'secret')
29 | .update('message')
30 | .digest('base64');
31 |
32 | expect(nodeSignature).toBe(jsSignature);
33 | });
34 |
35 | it('should get the same signature in node as in js with querystrings', async () => {
36 | const message =
37 | '/ae5ac453-f76e-4f95-a9d9-ecd865844990/episodes/9e077591-8874-4a1e-8a24-dc012603dae6/kapitel1.mp3?showUrl=skarmhjarnan&public=true&episodeId=9e077591-8874-4a1e-8a24-dc012603dae6';
38 | const secret = '694de11d-2883-4b39-a833-4265a48d276a';
39 |
40 | // Generate the SHA-256 hash from the secret string
41 | const key = await crypto.subtle.importKey(
42 | 'raw',
43 | str2ab(secret),
44 | { name: 'HMAC', hash: { name: 'SHA-256' } },
45 | false,
46 | ['sign', 'verify'],
47 | );
48 |
49 | // Sign the "str" with the key generated previously
50 | const sig = await crypto.subtle.sign({ name: 'HMAC' }, key, str2ab(message));
51 | const jsSignature = btoa(String.fromCharCode.apply(null, new Uint8Array(sig)));
52 |
53 | const nodeSignature = nodeCrypto.createHmac('SHA256', secret).update(message).digest('base64');
54 |
55 | expect(nodeSignature).toBe(jsSignature);
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/src/handlers/loadbalancer.ts:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash.get';
2 | import lodashSet from 'lodash.set';
3 | import constants from '../constants';
4 | import utils from '../utils';
5 |
6 | const _ = {
7 | get: lodashGet,
8 | set: lodashSet,
9 | };
10 |
11 | function filterCfHeaders(headers) {
12 | const result = {};
13 |
14 | Object.keys(headers).forEach((key) => {
15 | if (!key.startsWith('cf')) {
16 | result[key] = headers[key];
17 | }
18 | });
19 |
20 | return result;
21 | }
22 |
23 | function getSource(sources) {
24 | // Random for now. Maybe support sticky sessions, least connected or fallback
25 | return sources[Math.floor(Math.random() * sources.length)];
26 | }
27 |
28 | export default function loadbalancerHandler({ sources = [] }) {
29 | return async (ctx) => {
30 | const source = getSource(sources);
31 |
32 | const options = {
33 | method: ctx.request.method,
34 | headers: filterCfHeaders(ctx.request.headers),
35 | redirect: 'manual',
36 | // Allow other handlers to add cloudflare headers to the request
37 | cf: ctx.request.cf,
38 | };
39 |
40 | if (
41 | constants.methodsMethodsWithBody.indexOf(ctx.request.method) !== -1 &&
42 | _.get(ctx, 'event.request.body')
43 | ) {
44 | const clonedRequest = ctx.event.request.clone();
45 | options.body = clonedRequest.body;
46 | }
47 |
48 | const url = utils.resolveParams(source.url, ctx.params);
49 |
50 | if (source.resolveOverride) {
51 | const resolveOverride = utils.resolveParams(source.resolveOverride, ctx.request.params);
52 | // Cloudflare header to change host.
53 | // Only possible to add proxied cf dns within the same account.
54 | _.set(options, 'cf.resolveOverride', resolveOverride);
55 | }
56 |
57 | const response = await fetch(url + ctx.request.search, options);
58 |
59 | ctx.body = response.body;
60 | ctx.status = response.status;
61 | const responseHeaders = utils.instanceToJson(response.headers);
62 | Object.keys(responseHeaders).forEach((key) => {
63 | ctx.set(key, responseHeaders[key]);
64 | });
65 | };
66 | }
67 |
--------------------------------------------------------------------------------
/test/handlers/transformer.test.ts:
--------------------------------------------------------------------------------
1 | import transformFactory from '../../src/handlers/transform';
2 | import helpers from '../helpers';
3 |
4 | describe('transform', () => {
5 | it('should do a simple text replace', async () => {
6 | const regexHandler = transformFactory({
7 | transforms: [
8 | {
9 | regex: 'foo',
10 | replace: 'bar',
11 | },
12 | ],
13 | });
14 |
15 | const ctx = helpers.getCtx();
16 |
17 | ctx.status = 200;
18 | ctx.body = 'foo';
19 |
20 | await regexHandler(ctx, () => {});
21 |
22 | expect(ctx.body).toBe('bar');
23 | expect(ctx.status).toBe(200);
24 | });
25 |
26 | it('should replace multiple instances', async () => {
27 | const regexHandler = transformFactory({
28 | transforms: [
29 | {
30 | regex: 'foo',
31 | replace: 'bar',
32 | },
33 | ],
34 | });
35 |
36 | const ctx = helpers.getCtx();
37 |
38 | ctx.status = 200;
39 | ctx.body = 'foo-foo';
40 |
41 | await regexHandler(ctx, () => {});
42 |
43 | expect(ctx.body).toBe('bar-bar');
44 | expect(ctx.status).toBe(200);
45 | });
46 |
47 | it('should add text after the body tag', async () => {
48 | const transformHandler = transformFactory({
49 | transforms: [
50 | {
51 | regex: '',
52 | replace: '{{$0}}Hello',
53 | },
54 | ],
55 | });
56 |
57 | const ctx = helpers.getCtx();
58 |
59 | ctx.status = 200;
60 | ctx.body = '';
61 |
62 | await transformHandler(ctx, () => {});
63 |
64 | expect(ctx.body).toBe('Hello');
65 | expect(ctx.status).toBe(200);
66 | });
67 |
68 | it('should only transform on 200 status codes', async () => {
69 | const transformHandler = transformFactory({
70 | transforms: [
71 | {
72 | regex: 'foo',
73 | replace: 'bar',
74 | },
75 | ],
76 | });
77 |
78 | const ctx = helpers.getCtx();
79 |
80 | ctx.status = 404;
81 | ctx.body = 'foo';
82 |
83 | await transformHandler(ctx, () => {});
84 |
85 | expect(ctx.body).toBe('foo');
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/src/handlers/kv-storage-binding.ts:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash.get';
2 | import constants from '../constants';
3 | import utils from '../utils';
4 |
5 | const _ = {
6 | get: lodashGet,
7 | };
8 |
9 | function setDefaultLocation(url, defaultExtension, defaultIndexDocument) {
10 | if (url === '/' && defaultIndexDocument) {
11 | return defaultIndexDocument;
12 | }
13 |
14 | const file = url.split('/').pop();
15 | const extention = file.split('.').pop();
16 | if (extention !== file) {
17 | return url;
18 | }
19 |
20 | return `${url}.${defaultExtension}`;
21 | }
22 |
23 | function validateEtag(request, response) {
24 | const requestEtag = _.get(request, 'headers.if-none-match');
25 | const responseEtag = _.get(response, 'metadata.headers.etag');
26 |
27 | if (!requestEtag) {
28 | return false;
29 | }
30 |
31 | return requestEtag === responseEtag;
32 | }
33 |
34 | export default function kvStorageHandler({
35 | kvNamespaceBinding,
36 | kvBasePath = '',
37 | kvKey = '{file}',
38 | defaultExtension = 'html',
39 | defaultIndexDocument,
40 | defaultErrorDocument,
41 | }) {
42 | async function get(key) {
43 | const response = await global[kvNamespaceBinding].getWithMetadata(key);
44 |
45 | return response;
46 | }
47 |
48 | return async (ctx) => {
49 | const path = utils.resolveParams(kvKey, ctx.params);
50 |
51 | const key =
52 | path === '' && defaultIndexDocument
53 | ? defaultIndexDocument
54 | : setDefaultLocation(path, defaultExtension);
55 |
56 | let result = await get(kvBasePath + key);
57 |
58 | if (!result && defaultErrorDocument) {
59 | result = await get(kvBasePath + defaultErrorDocument);
60 | }
61 |
62 | if (result) {
63 | if (validateEtag(ctx.request, result)) {
64 | ctx.status = 304;
65 | } else {
66 | ctx.status = result.status;
67 | ctx.body = result.value;
68 |
69 | const headers = _.get(result, 'metadata.headers', {});
70 |
71 | Object.keys(headers).forEach((header) => {
72 | ctx.set(header, headers[header]);
73 | });
74 | }
75 | } else {
76 | ctx.status = 404;
77 | ctx.body = constants.http.statusMessages['404'];
78 | ctx.set('Content-Type', 'text/plain');
79 | }
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/test/handlers/s3.test.ts:
--------------------------------------------------------------------------------
1 | import s3Factory from '../../src/handlers/s3';
2 | import helpers from '../helpers';
3 |
4 | import fetchMock from 'fetch-mock';
5 | Object.assign(fetchMock.config, { Headers, Request, Response, fetch });
6 |
7 | describe('s3', () => {
8 | afterEach(() => {
9 | fetchMock.restore();
10 | });
11 | it('GET /doesnoteexist (403)', async () => {
12 | fetchMock.mock(`https://mybucket.s3.amazonaws.com/doesnoteexist`, {
13 | status: 403,
14 | });
15 | const s3 = s3Factory({
16 | bucket: 'myBucket',
17 | accessKeyId: 'DERP',
18 | secretAccessKey: 'DERP',
19 | });
20 |
21 | const ctx = helpers.getCtx();
22 | ctx.params = {
23 | file: 'doesnoteexist',
24 | };
25 | await s3(ctx);
26 | expect(ctx.status).toBe(403);
27 | });
28 |
29 | it('Custom endpoint with forcePathStyle', async () => {
30 | fetchMock.mock(`http://localhost:9000/myBucket/doesnoteexist`, {
31 | status: 200,
32 | });
33 | const s3 = s3Factory({
34 | endpoint: 'http://localhost:9000',
35 | forcePathStyle: true,
36 | bucket: 'myBucket',
37 | accessKeyId: 'DERP',
38 | secretAccessKey: 'DERP',
39 | });
40 |
41 | const ctx = helpers.getCtx();
42 | ctx.params = {
43 | file: 'doesnoteexist',
44 | };
45 | await s3(ctx);
46 | expect(ctx.status).toBe(200);
47 | });
48 |
49 | it('List bucket without enableBucketOperations should 404', async () => {
50 | const s3 = s3Factory({
51 | endpoint: 'http://localhost:9000',
52 | forcePathStyle: true,
53 | bucket: 'myBucket',
54 | accessKeyId: 'DERP',
55 | secretAccessKey: 'DERP',
56 | });
57 |
58 | const ctx = helpers.getCtx();
59 | ctx.params = {};
60 | await s3(ctx);
61 | expect(ctx.status).toBe(404);
62 | });
63 |
64 | it('List bucket with enableBucketOperations should forward to bucket URL', async () => {
65 | fetchMock.mock(`http://localhost:9000/myBucket`, {
66 | status: 200,
67 | });
68 |
69 | const s3 = s3Factory({
70 | endpoint: 'http://localhost:9000',
71 | forcePathStyle: true,
72 | bucket: 'myBucket',
73 | accessKeyId: 'DERP',
74 | secretAccessKey: 'DERP',
75 | enableBucketOperations: true,
76 | });
77 |
78 | const ctx = helpers.getCtx();
79 | ctx.params = {};
80 | await s3(ctx);
81 | expect(ctx.status).toBe(200);
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/src/loggers/chunker.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Concatinates messages in chunks based on count and timeout
3 | */
4 | export default class chunker {
5 | maxSeconds: number;
6 |
7 | maxSize: number;
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | queue: any[];
11 |
12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 | sink: any;
14 |
15 | flushing: boolean;
16 |
17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
18 | timer: any;
19 |
20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 | resolveTimer: any;
22 |
23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
24 | rejectTimer: any;
25 |
26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
27 | cancelationToken: any;
28 |
29 | constructor({ maxSize = 10, maxSeconds = 10, sink }) {
30 | this.maxSize = maxSize;
31 | this.maxSeconds = maxSeconds;
32 | this.queue = []; // The queue of messages to process
33 | this.sink = sink; // The function to call with a complete chunk
34 | this.flushing = false; // A state flag to avoid multiple simultaneous flushes
35 | this.timer = null; // A promise to pass to ctx.waitUntil
36 | }
37 |
38 | async push(message) {
39 | this.queue.push(message);
40 |
41 | if (this.queue.length > this.maxSize) {
42 | return this.flush();
43 | }
44 |
45 | if (!this.timer) {
46 | this.timer = new Promise((resolve, reject) => {
47 | // Expose the functions to resolve or reject the timer promise
48 | this.resolveTimer = resolve;
49 | this.rejectTimer = reject;
50 | this.cancelationToken = setTimeout(async () => {
51 | try {
52 | resolve(await this.flush());
53 | } catch (err) {
54 | reject(err);
55 | }
56 | }, this.maxSeconds * 1000);
57 | });
58 | }
59 |
60 | return this.timer;
61 | }
62 |
63 | async flush() {
64 | if (this.flushing) {
65 | return;
66 | }
67 |
68 | this.flushing = true;
69 |
70 | try {
71 | const data = this.queue.join('\n');
72 | this.queue = [];
73 |
74 | const result = await this.sink(data);
75 |
76 | if (this.timer) {
77 | clearTimeout(this.cancelationToken);
78 | this.resolveTimer(result);
79 | }
80 | } catch (err) {
81 | if (this.timer) {
82 | this.rejectTimer(err);
83 | }
84 | } finally {
85 | this.timer = null;
86 | this.flushing = false;
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/handlers/logger.ts:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash.get';
2 | import packageJson from '../../package.json';
3 | import HttpLogger from '../loggers/http';
4 | import KinesisLogger from '../loggers/kinesis';
5 |
6 | const _ = {
7 | get: lodashGet,
8 | };
9 |
10 | /**
11 | * Returns the first 10 KB of the body
12 | * @param {*} ctx
13 | */
14 | async function getBody(request) {
15 | if (['POST', 'PATCH'].indexOf(request.method) === -1) {
16 | return null;
17 | }
18 |
19 | return request.text();
20 | }
21 |
22 | export default function logger(options) {
23 | let logService;
24 |
25 | switch (options.type) {
26 | case 'http':
27 | logService = new HttpLogger(options);
28 | break;
29 | case 'kinesis':
30 | logService = new KinesisLogger(options);
31 | break;
32 | default:
33 | throw new Error(`Log service type not supported: ${options.type}`);
34 | }
35 |
36 | return async (ctx, next) => {
37 | ctx.state['logger-startDate'] = new Date();
38 | const body = await getBody(ctx.request);
39 |
40 | try {
41 | await next(ctx);
42 |
43 | const data = {
44 | message: 'START',
45 | requestIp: _.get(ctx, 'request.headers.x-real-ip'),
46 | requestId: _.get(ctx, 'request.requestId'),
47 | request: {
48 | headers: _.get(ctx, 'request.headers'),
49 | method: _.get(ctx, 'request.method'),
50 | url: _.get(ctx, 'request.href'),
51 | protocol: _.get(ctx, 'request.protocol'),
52 | body,
53 | },
54 | response: {
55 | status: ctx.status,
56 | headers: _.get(ctx, 'response.headers'),
57 | },
58 | handlers: _.get(ctx, 'state.handlers', []).join(','),
59 | route: _.get(ctx, 'route.name'),
60 | timestamp: new Date().toISOString(),
61 | ttfb: new Date() - ctx.state['logger-startDate'],
62 | redirectUrl: ctx.userRedirect,
63 | severity: 30,
64 | proxyVersion: packageJson.version,
65 | };
66 |
67 | ctx.event.waitUntil(logService.log(data));
68 | } catch (err) {
69 | const errData = {
70 | request: {
71 | headers: _.get(ctx, 'request.headers'),
72 | method: _.get(ctx, 'request.method'),
73 | handlers: _.get(ctx, 'state.handlers', []).join(','),
74 | url: _.get(ctx, 'request.href'),
75 | body,
76 | },
77 | message: 'ERROR',
78 | stack: err.stack,
79 | error: err.message,
80 | severity: 50,
81 | proxyVersion: packageJson.version,
82 | };
83 |
84 | ctx.event.waitUntil(logService.log(errData));
85 | }
86 | };
87 | }
88 |
--------------------------------------------------------------------------------
/src/handlers/cors.ts:
--------------------------------------------------------------------------------
1 | export default function corsHandler({
2 | allowedOrigins = ['*'],
3 | allowedMethods = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
4 | allowCredentials = true,
5 | allowedHeaders = ['Content-Type'],
6 | allowedExposeHeaders = ['WWW-Authenticate', 'Server-Authorization'],
7 | maxAge = 600,
8 | optionsSuccessStatus = 204,
9 | terminatePreflight = false,
10 | }) {
11 | return async (ctx, next) => {
12 | const { method } = ctx.request;
13 | const { origin } = ctx.request.headers;
14 | const requestHeaders = ctx.request.headers['access-control-request-headers'];
15 |
16 | configureOrigin(ctx, origin, allowedOrigins);
17 | configureCredentials(ctx, allowCredentials);
18 | configureExposedHeaders(ctx, allowedExposeHeaders);
19 | // handle preflight requests
20 | if (method === 'OPTIONS') {
21 | configureMethods(ctx, allowedMethods);
22 | configureAllowedHeaders(ctx, requestHeaders, allowedHeaders);
23 | configureMaxAge(ctx, maxAge);
24 | if (terminatePreflight) {
25 | ctx.status = optionsSuccessStatus;
26 | ctx.set('Content-Length', '0');
27 | ctx.body = '';
28 | return;
29 | }
30 | }
31 | await next(ctx);
32 | };
33 | }
34 |
35 | function configureOrigin(ctx, origin, allowedOrigins) {
36 | if (Array.isArray(allowedOrigins)) {
37 | if (allowedOrigins[0] === '*') {
38 | ctx.set('Access-Control-Allow-Origin', '*');
39 | } else if (allowedOrigins.indexOf(origin) !== -1) {
40 | ctx.set('Access-Control-Allow-Origin', origin);
41 | ctx.set('Vary', 'Origin');
42 | }
43 | }
44 | }
45 |
46 | function configureCredentials(ctx, allowCredentials) {
47 | if (allowCredentials) {
48 | ctx.set('Access-Control-Allow-Credentials', allowCredentials);
49 | }
50 | }
51 |
52 | function configureMethods(ctx, allowedMethods) {
53 | ctx.set('Access-Control-Allow-Methods', allowedMethods.join(','));
54 | }
55 |
56 | function configureAllowedHeaders(ctx, requestHeaders, allowedHeaders) {
57 | if (allowedHeaders.length === 0 && requestHeaders) {
58 | ctx.set('Access-Control-Allow-Headers', requestHeaders); // allowedHeaders wasn't specified, so reflect the request headers
59 | } else if (allowedHeaders.length) {
60 | ctx.set('Access-Control-Allow-Headers', allowedHeaders.join(','));
61 | }
62 | }
63 |
64 | function configureMaxAge(ctx, maxAge) {
65 | if (maxAge) {
66 | ctx.set('Access-Control-Max-Age', maxAge);
67 | }
68 | }
69 |
70 | function configureExposedHeaders(ctx, allowedExposeHeaders) {
71 | if (allowedExposeHeaders.length) {
72 | ctx.set('Access-Control-Expose-Headers', allowedExposeHeaders.join(','));
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/handlers/s3.ts:
--------------------------------------------------------------------------------
1 | import { AwsClient } from 'aws4fetch';
2 | import utils from '../utils';
3 | import constants from '../constants';
4 |
5 | function getEndpoint(
6 | endpoint?: string,
7 | options: { region?: string; bucket?: string; forcePathStyle?: boolean } = {},
8 | ) {
9 | // See https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-bucket-intro.html
10 | if (endpoint && options.forcePathStyle) {
11 | const url = new URL(endpoint);
12 | return `${url.protocol}//${url.host}/${options.bucket}`;
13 | }
14 | if (endpoint) {
15 | const url = new URL(endpoint);
16 | return `${url.protocol}//${options.bucket}.${url.host}`;
17 | }
18 | if (options.forcePathStyle && options.region) {
19 | return `https://s3.${options.region}.amazonaws.com/${options.bucket}`;
20 | }
21 | if (options.forcePathStyle) {
22 | return `https://s3.amazonaws.com/${options.bucket}`;
23 | }
24 | if (options.region) {
25 | return `https://${options.bucket}.s3.${options.region}.amazonaws.com`;
26 | }
27 | return `https://${options.bucket}.s3.amazonaws.com`;
28 | }
29 |
30 | export default function s3HandlerFactory({
31 | accessKeyId,
32 | secretAccessKey,
33 | bucket,
34 | region,
35 | endpoint,
36 | forcePathStyle,
37 | enableBucketOperations = false,
38 | }: {
39 | accessKeyId: string;
40 | secretAccessKey: string;
41 | bucket: string;
42 | region?: string;
43 | endpoint?: string;
44 | forcePathStyle?: boolean;
45 | enableBucketOperations?: boolean;
46 | }) {
47 | const aws = new AwsClient({
48 | accessKeyId,
49 | region,
50 | secretAccessKey,
51 | });
52 |
53 | const resolvedEndpoint = getEndpoint(endpoint, {
54 | region,
55 | bucket,
56 | forcePathStyle,
57 | });
58 |
59 | return async (ctx) => {
60 | if (ctx.params.file === undefined && !enableBucketOperations) {
61 | ctx.status = 404;
62 | ctx.body = constants.http.statusMessages['404'];
63 | ctx.set('Content-Type', 'text/plain');
64 | return;
65 | }
66 |
67 | const url = ctx.params.file
68 | ? utils.resolveParams(`${resolvedEndpoint}/{file}`, ctx.params)
69 | : resolvedEndpoint; // Bucket operations
70 |
71 | const headers: Record = {};
72 |
73 | if (ctx.request.headers.range) {
74 | headers.range = ctx.request.headers.range;
75 | }
76 |
77 | const response = await aws.fetch(url, {
78 | method: ctx.method || ctx.request.method,
79 | headers,
80 | });
81 |
82 | ctx.status = response.status;
83 | ctx.body = response.body;
84 | const responseHeaders = utils.instanceToJson(response.headers);
85 | Object.keys(responseHeaders).forEach((key) => {
86 | ctx.set(key, responseHeaders[key]);
87 | });
88 | };
89 | }
90 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cloudworker-proxy",
3 | "version": "1.0.0",
4 | "description": "An api gateway for cloudflare workers",
5 | "license": "MIT",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/markusahlstrand/cloudworker-proxy.git"
9 | },
10 | "bugs": {
11 | "url": "https://github.com/markusahlstrand/cloudworker-proxy/issues"
12 | },
13 | "homepage": "https://github.com/markusahlstrand/cloudworker-proxy#readme",
14 | "author": "Markus Ahlstrand",
15 | "keywords": [
16 | "cloudflare",
17 | "workers",
18 | "api",
19 | "gateway",
20 | "proxy"
21 | ],
22 | "main": "dist/index.js",
23 | "files": [
24 | "dist/**"
25 | ],
26 | "scripts": {
27 | "build": "esbuild --bundle src/index.ts --format=cjs --outdir=dist --sourcemap --minify",
28 | "lint": "eslint src",
29 | "package": "bun install; npm run build",
30 | "test": "npm run unit && npm run lint",
31 | "test:integration": "node integration/run.js",
32 | "unit": "bun test",
33 | "semantic-release": "semantic-release",
34 | "prepare": "husky install"
35 | },
36 | "release": {
37 | "branches": [
38 | "master"
39 | ],
40 | "plugins": [
41 | "@semantic-release/commit-analyzer",
42 | "@semantic-release/release-notes-generator",
43 | [
44 | "@semantic-release/npm",
45 | {
46 | "npmPublish": false
47 | }
48 | ],
49 | [
50 | "@semantic-release/git",
51 | {
52 | "assets": [
53 | "docs",
54 | "package.json"
55 | ],
56 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
57 | }
58 | ]
59 | ]
60 | },
61 | "dependencies": {
62 | "lodash.get": "4.4.2",
63 | "lodash.set": "4.3.2",
64 | "aws4fetch": "1.0.13",
65 | "cloudworker-router": "1.11.2",
66 | "shortid": "2.2.16",
67 | "cookie": "0.4.1"
68 | },
69 | "devDependencies": {
70 | "@semantic-release/git": "^10.0.1",
71 | "@types/jest": "^29.5.5",
72 | "@types/mocha": "^10.0.1",
73 | "@types/node": "^20.5.9",
74 | "@typescript-eslint/eslint-plugin": "^6.6.0",
75 | "@typescript-eslint/parser": "^6.6.0",
76 | "bun": "1.0.3",
77 | "dotenv": "8.2.0",
78 | "esbuild": "^0.19.2",
79 | "eslint": "7.13.0",
80 | "eslint-config-airbnb-base": "14.2.1",
81 | "eslint-config-prettier": "6.15.0",
82 | "eslint-plugin-import": "2.22.1",
83 | "eslint-plugin-prettier": "3.1.4",
84 | "fetch-mock": "9.11.0",
85 | "husky": "^8.0.3",
86 | "node-fetch": "2.6.1",
87 | "prettier": "2.1.2",
88 | "semantic-release": "^22.0.4",
89 | "typescript": "^5.2.2",
90 | "wrangler": "^3.7.0"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/handlers/jwt.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Parse and decode a JWT.
3 | * A JWT is three, base64 encoded, strings concatenated with ‘.’:
4 | * a header, a payload, and the signature.
5 | * The signature is “URL safe”, in that ‘/+’ characters have been replaced by ‘_-’
6 | *
7 | * Steps:
8 | * 1. Split the token at the ‘.’ character
9 | * 2. Base64 decode the individual parts
10 | * 3. Retain the raw Bas64 encoded strings to verify the signature
11 | */
12 | function decodeJwt(token) {
13 | const parts = token.split('.');
14 | const header = JSON.parse(atob(parts[0]));
15 | const payload = JSON.parse(atob(parts[1]));
16 | const signature = atob(parts[2].replace(/-/g, '+').replace(/_/g, '/'));
17 |
18 | return {
19 | header,
20 | payload,
21 | signature,
22 | raw: { header: parts[0], payload: parts[1], signature: parts[2] },
23 | };
24 | }
25 |
26 | export default function jwtHandler({ jwksUri, allowPublicAccess = false }) {
27 | async function getJwk() {
28 | // TODO: override jwksTtl..
29 | const response = await fetch(jwksUri);
30 |
31 | const body = await response.json();
32 | return body.keys;
33 | }
34 |
35 | async function isValidJwtSignature(token) {
36 | const encoder = new TextEncoder();
37 | const data = encoder.encode([token.raw.header, token.raw.payload].join('.'));
38 | const signature = new Uint8Array(Array.from(token.signature).map((c) => c.charCodeAt(0)));
39 |
40 | const jwkKeys = await getJwk();
41 |
42 | const validations = await Promise.all(
43 | jwkKeys.map(async (jwkKey) => {
44 | const key = await crypto.subtle.importKey(
45 | 'jwk',
46 | jwkKey,
47 | { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
48 | false,
49 | ['verify'],
50 | );
51 |
52 | return crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, data);
53 | }),
54 | );
55 |
56 | return validations.some((result) => result);
57 | }
58 |
59 | /**
60 | * Validates the request based on bearer token and cookie
61 | * @param {*} ctx
62 | * @param {*} next
63 | */
64 | async function handleValidate(ctx, next) {
65 | // Options requests should not be authenticated
66 | if (ctx.request.method === 'OPTIONS') {
67 | return next(ctx);
68 | }
69 |
70 | const authHeader = ctx.request.headers.authorization || '';
71 | if (authHeader.toLowerCase().startsWith('bearer')) {
72 | const token = decodeJwt(ctx.request.headers.authorization.slice(7));
73 |
74 | // Is the token expired?
75 | const expiryDate = new Date(token.payload.exp * 1000);
76 | const currentDate = new Date(Date.now());
77 | if (expiryDate <= currentDate) {
78 | return false;
79 | }
80 |
81 | if (await isValidJwtSignature(token)) {
82 | ctx.state.user = token.payload;
83 |
84 | return next(ctx);
85 | }
86 | }
87 |
88 | if (allowPublicAccess) {
89 | return next(ctx);
90 | }
91 |
92 | ctx.status = 403;
93 | ctx.body = 'Forbidden';
94 | return ctx;
95 | }
96 |
97 | return handleValidate;
98 | }
99 |
--------------------------------------------------------------------------------
/src/encryption/aes.ts:
--------------------------------------------------------------------------------
1 | const aesKeyBitsLength = 256;
2 | const pbkdf2Iterations = 1000;
3 |
4 | const PBKDF2 = 'PBKDF2';
5 | const AESGCM = 'AES-GCM';
6 | const SHA256 = 'SHA-256';
7 | const RAW = 'raw';
8 |
9 | function base64ToArraybuffer(base64) {
10 | const binary = atob(base64.replace(/_/g, '/').replace(/-/g, '+'));
11 | const len = binary.length;
12 | const bytes = new Uint8Array(len);
13 | for (let i = 0; i < len; i += 1) {
14 | bytes[i] = binary.charCodeAt(i);
15 | }
16 | return bytes.buffer;
17 | }
18 |
19 | function arraybufferTobase64(buffer) {
20 | let binary = '';
21 | const bytes = new Uint8Array(buffer);
22 | const len = bytes.byteLength;
23 | for (let i = 0; i < len; i += 1) {
24 | binary += String.fromCharCode(bytes[i]);
25 | }
26 |
27 | return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-');
28 | }
29 |
30 | function arraybufferToString(buf) {
31 | return String.fromCharCode.apply(null, new Uint16Array(buf));
32 | }
33 |
34 | function stringToArraybuffer(str) {
35 | const buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char
36 | const bufView = new Uint16Array(buf);
37 | for (let i = 0, strLen = str.length; i < strLen; i += 1) {
38 | bufView[i] = str.charCodeAt(i);
39 | }
40 | return buf;
41 | }
42 |
43 | async function getKeyMaterial(password) {
44 | const enc = new TextEncoder();
45 | return crypto.subtle.importKey(RAW, enc.encode(password), { name: PBKDF2 }, false, ['deriveKey']);
46 | }
47 |
48 | async function deriveAesGcmKey(seed, salt) {
49 | const key = await getKeyMaterial(seed);
50 | const textEncoder = new TextEncoder();
51 |
52 | const saltBuffer = textEncoder.encode(salt.replace(/_/g, '/').replace(/-/g, '+'));
53 |
54 | return crypto.subtle.deriveKey(
55 | {
56 | name: PBKDF2,
57 | salt: saltBuffer,
58 | iterations: pbkdf2Iterations,
59 | hash: { name: SHA256 },
60 | },
61 | key,
62 | {
63 | name: AESGCM,
64 | length: aesKeyBitsLength,
65 | },
66 | true,
67 | ['encrypt', 'decrypt'],
68 | );
69 | }
70 |
71 | async function getSalt() {
72 | const salt = crypto.getRandomValues(new Uint8Array(8));
73 | return arraybufferTobase64(salt);
74 | }
75 |
76 | async function decrypt(key, message) {
77 | const bytes = base64ToArraybuffer(message);
78 | const iv = bytes.slice(0, 16);
79 | const data = bytes.slice(16);
80 |
81 | const array = await crypto.subtle.decrypt(
82 | {
83 | name: AESGCM,
84 | iv,
85 | },
86 | key,
87 | data,
88 | );
89 |
90 | return arraybufferToString(array);
91 | }
92 |
93 | async function encrypt(key, message) {
94 | const iv = crypto.getRandomValues(new Uint8Array(16));
95 |
96 | const encrypted = await crypto.subtle.encrypt(
97 | {
98 | name: AESGCM,
99 | iv,
100 | },
101 | key,
102 | stringToArraybuffer(message),
103 | );
104 |
105 | const bytes = new Uint8Array(encrypted.byteLength + iv.byteLength);
106 | bytes.set(iv, 0);
107 | bytes.set(new Uint8Array(encrypted), iv.byteLength);
108 |
109 | return arraybufferTobase64(bytes);
110 | }
111 |
112 | export default {
113 | decrypt,
114 | deriveAesGcmKey,
115 | encrypt,
116 | getSalt,
117 | };
118 |
--------------------------------------------------------------------------------
/src/handlers/cache.ts:
--------------------------------------------------------------------------------
1 | import cacheService from '../services/cache';
2 | import hash from '../encryption/hash';
3 | import { instanceToJson } from '../utils';
4 |
5 | const defaultHeaderBlacklist = [
6 | 'x-ratelimit-count',
7 | 'x-ratelimit-limit',
8 | 'x-ratelimit-reset',
9 | 'x-cache-hit',
10 | ];
11 |
12 | async function getBody(request) {
13 | if (['POST', 'PATCH'].indexOf(request.method) === -1) {
14 | return null;
15 | }
16 |
17 | return request.text();
18 | }
19 |
20 | async function getCacheKey(ctx, cacheKeyTemplate) {
21 | if (!cacheKeyTemplate) {
22 | return ctx.event.request;
23 | }
24 |
25 | const cacheKeys = cacheKeyTemplate.match(/{.*?}/gi).map((key) => key.slice(1, -1));
26 | const cacheKeyValues = {};
27 |
28 | for (let i = 0; i < cacheKeys.length; i += 1) {
29 | const cacheKey = cacheKeys[i];
30 | const segments = cacheKey.split(':');
31 |
32 | switch (segments[0]) {
33 | case 'method':
34 | cacheKeyValues[cacheKey] = ctx.request.method;
35 | break;
36 | case 'path':
37 | cacheKeyValues[cacheKey] = ctx.request.path;
38 | break;
39 | case 'bodyHash':
40 | // eslint-disable-next-line no-await-in-loop
41 | cacheKeyValues[cacheKey] = await hash(await getBody(ctx.request));
42 | break;
43 | case 'header':
44 | cacheKeyValues[cacheKey] = ctx.request.headers[segments[1]] || '';
45 | break;
46 | default:
47 | cacheKeyValues[cacheKey] = cacheKey;
48 | }
49 | }
50 |
51 | const cacheKeyPath = encodeURIComponent(
52 | cacheKeyTemplate.replace(/({(.*?)})/gi, ($0, $1, key) => {
53 | return cacheKeyValues[key];
54 | }),
55 | );
56 |
57 | return new Request(`http://${ctx.request.hostname}/${cacheKeyPath}`);
58 | }
59 |
60 | export default function cacheFactory({
61 | cacheDuration,
62 | cacheKeyTemplate,
63 | headerBlacklist = defaultHeaderBlacklist,
64 | }) {
65 | return async (ctx, next) => {
66 | const cacheKey = await getCacheKey(ctx, cacheKeyTemplate);
67 |
68 | const cachedResponse = await cacheService.get(cacheKey);
69 |
70 | if (cachedResponse) {
71 | ctx.body = cachedResponse.body;
72 | ctx.status = cachedResponse.status;
73 |
74 | const headers = instanceToJson(cachedResponse.headers);
75 |
76 | Object.keys(headers).forEach((key) => {
77 | ctx.set(key, headers[key]);
78 | });
79 | ctx.set('X-Cache-Hit', true);
80 | } else {
81 | await next(ctx);
82 |
83 | let clonedBody;
84 |
85 | if (ctx.body.tee) {
86 | [ctx.body, clonedBody] = ctx.body.tee();
87 | } else {
88 | clonedBody = ctx.body;
89 | }
90 |
91 | const response = new Response(clonedBody, {
92 | status: ctx.status,
93 | });
94 |
95 | Object.keys(ctx.response.headers).forEach((header) => {
96 | if (headerBlacklist.indexOf(header.toLowerCase()) === -1) {
97 | response.headers.set(header, ctx.response.headers[header]);
98 | }
99 | });
100 |
101 | if (cacheDuration) {
102 | response.headers.delete('Cache-Control');
103 | response.headers.set('Cache-Control', `max-age=${cacheDuration}`);
104 | }
105 |
106 | ctx.event.waitUntil(cacheService.set(cacheKey, response));
107 | }
108 | };
109 | }
110 |
--------------------------------------------------------------------------------
/src/services/kv-storage.ts:
--------------------------------------------------------------------------------
1 | import lodashGet from 'lodash.get';
2 |
3 | const _ = {
4 | get: lodashGet,
5 | };
6 |
7 | /**
8 | * This replaces the in-worker api calls for kv-storage with rest-api calls.
9 | */
10 |
11 | export default class KvStorage {
12 | accountId: string;
13 |
14 | namespace: string;
15 |
16 | authEmail: string;
17 |
18 | authKey: string;
19 |
20 | ttl: number;
21 |
22 | constructor({
23 | accountId,
24 | namespace,
25 | authEmail,
26 | authKey,
27 | ttl,
28 | }: {
29 | accountId: string;
30 | namespace: string;
31 | authEmail: string;
32 | authKey: string;
33 | ttl: number;
34 | }) {
35 | this.accountId = accountId;
36 | this.namespace = namespace;
37 | this.authEmail = authEmail;
38 | this.authKey = authKey;
39 | this.ttl = ttl;
40 | }
41 |
42 | getNamespaceUrl() {
43 | return new URL(
44 | `https://api.cloudflare.com/client/v4/accounts/${this.accountId}/storage/kv/namespaces/${this.namespace}`,
45 | );
46 | }
47 |
48 | getUrlForKey(key) {
49 | return new URL(`${this.getNamespaceUrl()}/values/${key}`);
50 | }
51 |
52 | async list(prefix, limit = 10) {
53 | const url = `${this.getNamespaceUrl()}/keys?prefix=${prefix}&limit=${limit}`;
54 |
55 | const response = await fetch(url, {
56 | headers: {
57 | 'X-Auth-Email': this.authEmail,
58 | 'X-Auth-Key': this.authKey,
59 | },
60 | });
61 |
62 | if (response.ok) {
63 | return response.json();
64 | }
65 | return null;
66 | }
67 |
68 | async get(key, type?: string) {
69 | const url = this.getUrlForKey(key);
70 |
71 | const response = await fetch(url, {
72 | headers: {
73 | 'X-Auth-Email': this.authEmail,
74 | 'X-Auth-Key': this.authKey,
75 | },
76 | });
77 |
78 | if (response.ok) {
79 | switch (type) {
80 | case 'json':
81 | return response.json();
82 | case 'stream':
83 | return response;
84 | case 'arrayBuffer':
85 | return response.arrayBuffer();
86 | default:
87 | return response.text();
88 | }
89 | }
90 |
91 | return null;
92 | }
93 |
94 | async getWithMetadata(key, type) {
95 | const [value, keys] = await Promise.all([this.get(key, type), this.list(key)]);
96 |
97 | const metadata = _.get(keys, 'result.0.metadata', {});
98 | return {
99 | value,
100 | metadata,
101 | };
102 | }
103 |
104 | async put(key, value, metadata = {}) {
105 | const url = this.getUrlForKey(key);
106 | const searchParams = new URLSearchParams();
107 |
108 | if (this.ttl) {
109 | searchParams.append('expiration_ttl', this.ttl.toString());
110 | }
111 |
112 | const headers = {
113 | 'X-Auth-Email': this.authEmail,
114 | 'X-Auth-Key': this.authKey,
115 | };
116 |
117 | url.search = searchParams.toString();
118 |
119 | const formData = new FormData();
120 | formData.append('value', value);
121 | formData.append('metadata', JSON.stringify(metadata));
122 |
123 | const response = await fetch(url.toString(), {
124 | method: 'PUT',
125 | headers,
126 | body: value,
127 | });
128 |
129 | return response.ok;
130 | }
131 |
132 | async delete(key) {
133 | const url = this.getUrlForKey(key);
134 |
135 | return fetch(url, {
136 | method: 'DELETE',
137 | headers: {
138 | 'X-Auth-Email': this.authEmail,
139 | 'X-Auth-Key': this.authKey,
140 | },
141 | });
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/handlers/geo-decorator.ts:
--------------------------------------------------------------------------------
1 | // Data from https://datahub.io/JohnSnowLabs/country-and-continent-codes-list#data
2 | const countryRegions = {
3 | AF: 'AS',
4 | AL: 'EU',
5 | AQ: 'AN',
6 | DZ: 'AF',
7 | AS: 'OC',
8 | AD: 'EU',
9 | AO: 'AF',
10 | AG: 'NA',
11 | AZ: 'EU',
12 | AR: 'SA',
13 | AU: 'OC',
14 | AT: 'EU',
15 | BS: 'NA',
16 | BH: 'AS',
17 | BD: 'AS',
18 | AM: 'EU',
19 | BB: 'NA',
20 | BE: 'EU',
21 | BM: 'NA',
22 | BT: 'AS',
23 | BO: 'SA',
24 | BA: 'EU',
25 | BW: 'AF',
26 | BV: 'AN',
27 | BR: 'SA',
28 | BZ: 'NA',
29 | IO: 'AS',
30 | SB: 'OC',
31 | VG: 'NA',
32 | BN: 'AS',
33 | BG: 'EU',
34 | MM: 'AS',
35 | BI: 'AF',
36 | BY: 'EU',
37 | KH: 'AS',
38 | CM: 'AF',
39 | CA: 'NA',
40 | CV: 'AF',
41 | KY: 'NA',
42 | CF: 'AF',
43 | LK: 'AS',
44 | TD: 'AF',
45 | CL: 'SA',
46 | CN: 'AS',
47 | TW: 'AS',
48 | CX: 'AS',
49 | CC: 'AS',
50 | CO: 'SA',
51 | KM: 'AF',
52 | YT: 'AF',
53 | CG: 'AF',
54 | CD: 'AF',
55 | CK: 'OC',
56 | CR: 'NA',
57 | HR: 'EU',
58 | CU: 'NA',
59 | CY: 'EU',
60 | CZ: 'EU',
61 | BJ: 'AF',
62 | DK: 'EU',
63 | DM: 'NA',
64 | DO: 'NA',
65 | EC: 'SA',
66 | SV: 'NA',
67 | GQ: 'AF',
68 | ET: 'AF',
69 | ER: 'AF',
70 | EE: 'EU',
71 | FO: 'EU',
72 | FK: 'SA',
73 | GS: 'AN',
74 | FJ: 'OC',
75 | FI: 'EU',
76 | AX: 'EU',
77 | FR: 'EU',
78 | GF: 'SA',
79 | PF: 'OC',
80 | TF: 'AN',
81 | DJ: 'AF',
82 | GA: 'AF',
83 | GE: 'EU',
84 | GM: 'AF',
85 | PS: 'AS',
86 | DE: 'EU',
87 | GH: 'AF',
88 | GI: 'EU',
89 | KI: 'OC',
90 | GR: 'EU',
91 | GL: 'NA',
92 | GD: 'NA',
93 | GP: 'NA',
94 | GU: 'OC',
95 | GT: 'NA',
96 | GN: 'AF',
97 | GY: 'SA',
98 | HT: 'NA',
99 | HM: 'AN',
100 | VA: 'EU',
101 | HN: 'NA',
102 | HK: 'AS',
103 | HU: 'EU',
104 | IS: 'EU',
105 | IN: 'AS',
106 | ID: 'AS',
107 | IR: 'AS',
108 | IQ: 'AS',
109 | IE: 'EU',
110 | IL: 'AS',
111 | IT: 'EU',
112 | CI: 'AF',
113 | JM: 'NA',
114 | JP: 'AS',
115 | KZ: 'EU',
116 | JO: 'AS',
117 | KE: 'AF',
118 | KP: 'AS',
119 | KR: 'AS',
120 | KW: 'AS',
121 | KG: 'AS',
122 | LA: 'AS',
123 | LB: 'AS',
124 | LS: 'AF',
125 | LV: 'EU',
126 | LR: 'AF',
127 | LY: 'AF',
128 | LI: 'EU',
129 | LT: 'EU',
130 | LU: 'EU',
131 | MO: 'AS',
132 | MG: 'AF',
133 | MW: 'AF',
134 | MY: 'AS',
135 | MV: 'AS',
136 | ML: 'AF',
137 | MT: 'EU',
138 | MQ: 'NA',
139 | MR: 'AF',
140 | MU: 'AF',
141 | MX: 'NA',
142 | MC: 'EU',
143 | MN: 'AS',
144 | MD: 'EU',
145 | ME: 'EU',
146 | MS: 'NA',
147 | MA: 'AF',
148 | MZ: 'AF',
149 | OM: 'AS',
150 | NA: 'AF',
151 | NR: 'OC',
152 | NP: 'AS',
153 | NL: 'EU',
154 | AN: 'NA',
155 | CW: 'NA',
156 | AW: 'NA',
157 | SX: 'NA',
158 | BQ: 'NA',
159 | NC: 'OC',
160 | VU: 'OC',
161 | NZ: 'OC',
162 | NI: 'NA',
163 | NE: 'AF',
164 | NG: 'AF',
165 | NU: 'OC',
166 | NF: 'OC',
167 | NO: 'EU',
168 | MP: 'OC',
169 | UM: 'OC',
170 | FM: 'OC',
171 | MH: 'OC',
172 | PW: 'OC',
173 | PK: 'AS',
174 | PA: 'NA',
175 | PG: 'OC',
176 | PY: 'SA',
177 | PE: 'SA',
178 | PH: 'AS',
179 | PN: 'OC',
180 | PL: 'EU',
181 | PT: 'EU',
182 | GW: 'AF',
183 | TL: 'AS',
184 | PR: 'NA',
185 | QA: 'AS',
186 | RE: 'AF',
187 | RO: 'EU',
188 | RU: 'EU',
189 | RW: 'AF',
190 | BL: 'NA',
191 | SH: 'AF',
192 | KN: 'NA',
193 | AI: 'NA',
194 | LC: 'NA',
195 | MF: 'NA',
196 | PM: 'NA',
197 | VC: 'NA',
198 | SM: 'EU',
199 | ST: 'AF',
200 | SA: 'AS',
201 | SN: 'AF',
202 | RS: 'EU',
203 | SC: 'AF',
204 | SL: 'AF',
205 | SG: 'AS',
206 | SK: 'EU',
207 | VN: 'AS',
208 | SI: 'EU',
209 | SO: 'AF',
210 | ZA: 'AF',
211 | ZW: 'AF',
212 | ES: 'EU',
213 | SS: 'AF',
214 | EH: 'AF',
215 | SD: 'AF',
216 | SR: 'SA',
217 | SJ: 'EU',
218 | SZ: 'AF',
219 | SE: 'EU',
220 | CH: 'EU',
221 | SY: 'AS',
222 | TJ: 'AS',
223 | TH: 'AS',
224 | TG: 'AF',
225 | TK: 'OC',
226 | TO: 'OC',
227 | TT: 'NA',
228 | AE: 'AS',
229 | TN: 'AF',
230 | TR: 'EU',
231 | TM: 'AS',
232 | TC: 'NA',
233 | TV: 'OC',
234 | UG: 'AF',
235 | UA: 'EU',
236 | MK: 'EU',
237 | EG: 'AF',
238 | GB: 'EU',
239 | GG: 'EU',
240 | JE: 'EU',
241 | IM: 'EU',
242 | TZ: 'AF',
243 | US: 'NA',
244 | VI: 'NA',
245 | BF: 'AF',
246 | UY: 'SA',
247 | UZ: 'AS',
248 | VE: 'SA',
249 | WF: 'OC',
250 | WS: 'OC',
251 | YE: 'AS',
252 | ZM: 'AF',
253 | XX: 'XX',
254 | };
255 |
256 | export default function geoHandler() {
257 | return async (ctx, next) => {
258 | const country = ctx.request.headers['cf-ipcountry'] || 'XX';
259 |
260 | ctx.request.headers['proxy-continent'] = countryRegions[country];
261 |
262 | await next(ctx);
263 | };
264 | }
265 |
--------------------------------------------------------------------------------
/test/handlers/cors.test.ts:
--------------------------------------------------------------------------------
1 | import CorsHandler from '../../src/handlers/cors';
2 | import helpers from '../helpers';
3 |
4 | describe('corsHandler', () => {
5 | it('should not return Access-Control-Allow-Origin if the origin is not in the allowed headers list', async () => {
6 | const corsHandler = CorsHandler({
7 | allowedOrigins: [],
8 | });
9 |
10 | const ctx = helpers.getCtx();
11 |
12 | await corsHandler(ctx, helpers.getNext());
13 |
14 | expect(ctx.response.headers.get('Access-Control-Allow-Origin')).toBe(undefined);
15 | });
16 | it('should return Access-Control-Allow-Origin "*", if allowedOrigin = ["*"]', async () => {
17 | const corsHandler = CorsHandler({
18 | allowedOrigins: ['*'],
19 | });
20 | const ctx = helpers.getCtx();
21 | await corsHandler(ctx, helpers.getNext());
22 | expect(ctx.response.headers.get('Access-Control-Allow-Origin')).toBe('*');
23 | });
24 | it('should return Access-Control-Allow-Origin, if Origin is in allowedOrigin array', async () => {
25 | const corsHandler = CorsHandler({
26 | allowedOrigins: ['somehost', 'localhost'],
27 | });
28 | const ctx = helpers.getCtx();
29 | await corsHandler(ctx, helpers.getNext());
30 | expect(ctx.response.headers.get('Access-Control-Allow-Origin')).toBe('localhost');
31 | });
32 | it('should return Access-Control-Expose-Headers that was configured', async () => {
33 | const corsHandler = CorsHandler({
34 | allowedExposeHeaders: ['Header1', 'Header2'],
35 | });
36 | const ctx = helpers.getCtx();
37 | await corsHandler(ctx, helpers.getNext());
38 | expect(ctx.response.headers.get('Access-Control-Expose-Headers')).toBe('Header1,Header2');
39 | });
40 | it('should return Access-Control-Allow-Credentials by default', async () => {
41 | const corsHandler = CorsHandler({});
42 | const ctx = helpers.getCtx();
43 | await corsHandler(ctx, helpers.getNext());
44 | expect(ctx.response.headers.get('Access-Control-Allow-Credentials')).toBe(true);
45 | });
46 | it('should not return Access-Control-Allow-Credentials if it was set to false', async () => {
47 | const corsHandler = CorsHandler({
48 | allowCredentials: false,
49 | });
50 | const ctx = helpers.getCtx();
51 | await corsHandler(ctx, helpers.getNext());
52 | expect(ctx.response.headers.get('Access-Control-Allow-Credentials')).toBe(undefined);
53 | });
54 | it('should not return Access-Control-Allow-Methods if method is not options', async () => {
55 | const corsHandler = CorsHandler({});
56 | const ctx = helpers.getCtx();
57 | await corsHandler(ctx, helpers.getNext());
58 | expect(ctx.response.headers.get('Access-Control-Allow-Methods')).toBe(undefined);
59 | });
60 | it('should return Access-Control-Allow-Methods if method is OPTIONS', async () => {
61 | const corsHandler = CorsHandler({});
62 | const ctx = helpers.getCtx();
63 | ctx.request.method = 'OPTIONS';
64 | await corsHandler(ctx, helpers.getNext());
65 | expect(ctx.response.headers.get('Access-Control-Allow-Methods')).toBeDefined();
66 | });
67 | it('should return Access-Control-Allow-Methods with the methods that were configured', async () => {
68 | const corsHandler = CorsHandler({
69 | allowedMethods: ['POST', 'GET'],
70 | });
71 | const ctx = helpers.getCtx();
72 | ctx.request.method = 'OPTIONS';
73 | await corsHandler(ctx, helpers.getNext());
74 | expect(ctx.response.headers.get('Access-Control-Allow-Methods')).toBe('POST,GET');
75 | });
76 | it('should not return Access-Control-Allow-Headers if method is not options', async () => {
77 | const corsHandler = CorsHandler({});
78 | const ctx = helpers.getCtx();
79 | await corsHandler(ctx, helpers.getNext());
80 | expect(ctx.response.headers.get('Access-Control-Allow-Headers')).toBe(undefined);
81 | });
82 | it('should return Access-Control-Allow-Headers if method is OPTIONS', async () => {
83 | const corsHandler = CorsHandler({});
84 | const ctx = helpers.getCtx();
85 | ctx.request.method = 'OPTIONS';
86 | await corsHandler(ctx, helpers.getNext());
87 | expect(ctx.response.headers.get('Access-Control-Allow-Headers')).toBeDefined();
88 | });
89 | it("should return Access-Control-Allow-Headers with the request's requested headers if allowedHeaders is set to []", async () => {
90 | const corsHandler = CorsHandler({
91 | allowedHeaders: [],
92 | });
93 | const ctx = helpers.getCtx();
94 | ctx.request.method = 'OPTIONS';
95 | ctx.request.headers['access-control-request-headers'] = 'Header1,Header2';
96 | await corsHandler(ctx, helpers.getNext());
97 | expect(ctx.response.headers.get('Access-Control-Allow-Headers')).toBe('Header1,Header2');
98 | });
99 | it('should return Access-Control-Allow-Headers with the allowedHeaders', async () => {
100 | const corsHandler = CorsHandler({
101 | allowedHeaders: ['Header1', 'Header2'],
102 | });
103 | const ctx = helpers.getCtx();
104 | ctx.request.method = 'OPTIONS';
105 | await corsHandler(ctx, helpers.getNext());
106 | expect(ctx.response.headers.get('Access-Control-Allow-Headers')).toBe('Header1,Header2');
107 | });
108 | it('should not return Access-Control-Max-Age if method is not OPTIONS', async () => {
109 | const corsHandler = CorsHandler({});
110 | const ctx = helpers.getCtx();
111 | await corsHandler(ctx, helpers.getNext());
112 | expect(ctx.response.headers.get('Access-Control-Max-Age')).toBe(undefined);
113 | });
114 | it('should return Access-Control-Max-Age if method is OPTIONS', async () => {
115 | const corsHandler = CorsHandler({});
116 | const ctx = helpers.getCtx();
117 | ctx.request.method = 'OPTIONS';
118 | await corsHandler(ctx, helpers.getNext());
119 | expect(ctx.response.headers.get('Access-Control-Max-Age')).toBeDefined();
120 | });
121 | it('should return Access-Control-Max-Age with the configured maxAge', async () => {
122 | const corsHandler = CorsHandler({
123 | maxAge: 1200,
124 | });
125 | const ctx = helpers.getCtx();
126 | ctx.request.method = 'OPTIONS';
127 | await corsHandler(ctx, helpers.getNext());
128 | expect(ctx.response.headers.get('Access-Control-Max-Age')).toBe(1200);
129 | });
130 | it('should return no body if method is OPTIONS and terminatePreflight is set', async () => {
131 | const corsHandler = CorsHandler({
132 | terminatePreflight: true,
133 | });
134 | const ctx = helpers.getCtx();
135 | ctx.request.method = 'OPTIONS';
136 | await corsHandler(ctx, helpers.getNext());
137 | expect(ctx.response.body).toBe(undefined);
138 | });
139 | it('should return no Content-Length:0 header if method is OPTIONS and terminatePreflight is set', async () => {
140 | const corsHandler = CorsHandler({
141 | terminatePreflight: true,
142 | });
143 | const ctx = helpers.getCtx();
144 | ctx.request.method = 'OPTIONS';
145 | await corsHandler(ctx, helpers.getNext());
146 | expect(ctx.response.headers.get('Content-Length')).toBe('0');
147 | });
148 | it('should return response 204 if method is OPTIONS and terminatePreflight is set', async () => {
149 | const corsHandler = CorsHandler({
150 | terminatePreflight: true,
151 | });
152 | const ctx = helpers.getCtx();
153 | ctx.request.method = 'OPTIONS';
154 | await corsHandler(ctx, helpers.getNext());
155 | expect(ctx.status).toBe(204);
156 | });
157 | it('should return response defined in optionsSuccessStatus if method is OPTIONS and terminatePreflight is set', async () => {
158 | const corsHandler = CorsHandler({
159 | terminatePreflight: true,
160 | optionsSuccessStatus: 200,
161 | });
162 | const ctx = helpers.getCtx();
163 | ctx.request.method = 'OPTIONS';
164 | await corsHandler(ctx, helpers.getNext());
165 | expect(ctx.status).toBe(200);
166 | });
167 | });
168 |
--------------------------------------------------------------------------------
/examples/handler.js:
--------------------------------------------------------------------------------
1 | const Proxy = require('../src/index');
2 |
3 | const rules = [
4 | {
5 | // This rule is place before the logger and ratelimit as it create a new separate request
6 | path: '/split',
7 | handlerName: 'split',
8 | options: {
9 | host: 'split.localhost',
10 | },
11 | },
12 | {
13 | handlerName: 'geoDecorator',
14 | path: '/geo',
15 | options: {},
16 | },
17 | {
18 | handlerName: 'logger',
19 | options: {
20 | type: 'http',
21 | url: process.env.LOGZ_IO_URL,
22 | contentType: 'text/plain',
23 | delimiter: '_',
24 | },
25 | },
26 | // {
27 | // handlerName: 'logger',
28 | // options: {
29 | // type: 'kinesis',
30 | // region: 'us-east-1',
31 | // accessKeyId: process.env.AWS_ACCESS_KEY_ID,
32 | // secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
33 | // streamName: 'cloudworker-proxy',
34 | // },
35 | // },
36 | {
37 | handlerName: 'rateLimit',
38 | options: {},
39 | },
40 | {
41 | handlerName: 'response',
42 | protocol: 'http',
43 | host: 'proxy.cloudproxy.io',
44 | options: {
45 | status: 302,
46 | body: 'Redirect to https',
47 | headers: {
48 | location: 'https://proxy.cloudproxy.io',
49 | },
50 | },
51 | },
52 | {
53 | handlerName: 'response',
54 | path: '/',
55 | options: {
56 | body: {
57 | description: 'Sample endpoints for the cloudworker-proxy',
58 | links: [
59 | {
60 | name: 'split',
61 | description: 'Splits the request pipeline into two separate pipelines',
62 | url: 'https://proxy.cloudproxy.io/split',
63 | },
64 | {
65 | name: 'Basic auth',
66 | description: 'Protects a resource with basic auth',
67 | url: 'https://proxy.cloudproxy.io/basic/test',
68 | },
69 | {
70 | name: 'geo',
71 | description: 'Routes to different pages depending on geo',
72 | url: 'https://proxy.cloudproxy.io/geo',
73 | },
74 | {
75 | name: 'Response',
76 | description: 'Generates a static response straight from the edge',
77 | url: 'https://proxy.cloudproxy.io/edge',
78 | },
79 | {
80 | name: 'S3 + cache',
81 | description: 'Fetches file from S3 and caches using cloudflare cache',
82 | url: 'https://proxy.cloudproxy.io/s3/logo.png',
83 | },
84 | {
85 | name: 'Basic auth',
86 | description: 'Protects a resource with oAuth2. In this case with auth0',
87 | url: 'https://proxy.cloudproxy.io/oauth2/test',
88 | },
89 | {
90 | name: 'Transform response',
91 | description: 'Rewrite responses using regular expressions',
92 | url: 'https://proxy.cloudproxy.io/transform',
93 | },
94 | {
95 | name: 'Invoke lambda',
96 | description:
97 | 'Invokes a lambda straight from the edge without paying for the api gateway from aws',
98 | url: 'https://proxy.cloudproxy.io/lambda/test',
99 | },
100 | {
101 | name: 'Invoke google cloud function',
102 | description:
103 | 'Invokes a google cloud function via http. Makes it easier to get custom domains working',
104 | url: 'https://proxy.cloudproxy.io/google/test',
105 | },
106 | ],
107 | },
108 | },
109 | },
110 | {
111 | handlerName: 'response',
112 | path: '/geo',
113 | headers: {
114 | 'proxy-continent': 'EU',
115 | },
116 | options: {
117 | body: 'This is served to clients in EU',
118 | },
119 | },
120 | {
121 | handlerName: 'response',
122 | path: '/geo',
123 | options: {
124 | body: 'This is served to clients outside the EU',
125 | },
126 | },
127 | {
128 | handlerName: 'response',
129 | host: 'localhost:3000',
130 | path: '/split',
131 | options: {
132 | body: 'This request is split to a separate request',
133 | },
134 | },
135 | {
136 | handlerName: 'response',
137 | host: 'split.localhost',
138 | options: {
139 | body: 'This reponse is only available on the splitted request',
140 | },
141 | },
142 | {
143 | handlerName: 'basicAuth',
144 | path: '/basic/:path*',
145 | options: {
146 | users: [
147 | {
148 | userhandlerName: 'test',
149 | authToken: 'dGVzdDpwYXNzd29yZA==', // "password" Base64 encoded
150 | },
151 | ],
152 | logoutPath: '/basic/logout',
153 | },
154 | },
155 | {
156 | handlerName: 'response',
157 | path: '/basic.*',
158 | options: {
159 | body: 'Very secret',
160 | },
161 | },
162 | {
163 | handlerName: 'cors',
164 | path: '/edge',
165 | options: {},
166 | },
167 | {
168 | handlerName: 'transform',
169 | path: '/edge',
170 | options: {
171 | transforms: [
172 | {
173 | regex: '.*',
174 | replace: '{{$0}} with a transformed result',
175 | },
176 | ],
177 | },
178 | },
179 | {
180 | handlerName: 'cache',
181 | path: '/s3/:file*',
182 | options: {
183 | cacheDuration: 60,
184 | },
185 | },
186 | {
187 | handlerName: 's3',
188 | path: '/s3/:file*',
189 | options: {
190 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
191 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
192 | region: 'eu-north-1',
193 | bucket: 'cloudproxy-test',
194 | path: '{file}',
195 | },
196 | },
197 | {
198 | handlerName: 'signature',
199 | path: '/ae5ac453-f76e-4f95-a9d9-ecd865844990/:file*',
200 | options: {
201 | secret: process.env.SIGNATURE_SECRET,
202 | },
203 | },
204 | {
205 | handlerName: 'response',
206 | path: '/edge',
207 | options: {
208 | body: 'This is a static page served directly from the edge',
209 | },
210 | },
211 | {
212 | handlerName: 'transform',
213 | path: '/transform',
214 | options: {
215 | transforms: [
216 | {
217 | regex: '',
218 | replace: '{{$0}}',
219 | },
220 | ],
221 | },
222 | },
223 | {
224 | handlerName: 'response',
225 | path: '/transform',
226 | options: {
227 | body: 'A html page',
228 | headers: {
229 | 'content-type': 'text/html',
230 | },
231 | },
232 | },
233 | {
234 | handlerName: 'custom',
235 | path: '/custom',
236 | options: {},
237 | },
238 | {
239 | handlerName: 'lambda',
240 | path: '/lambda/.*',
241 | options: {
242 | region: 'us-east-1',
243 | lambdaName: 'lambda-hello-dev-hello',
244 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
245 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
246 | },
247 | },
248 | {
249 | handlerName: 'kvStorage',
250 | path: '/kvStorage/:file*',
251 | options: {
252 | kvAccountId: process.env.KV_ACCOUNT_ID,
253 | kvNamespace: process.env.KV_NAMESPACE_TEMPLATES,
254 | kvAuthEmail: process.env.KV_AUTH_EMAIL,
255 | kvAuthKey: process.env.KV_AUTH_KEY,
256 | kvKey: '{file}',
257 | },
258 | },
259 | {
260 | handlerName: 'kvStorageBinding',
261 | path: '/kvStorageBinding/:file*',
262 | options: {
263 | kvNamespaceBinding: 'TEST_NAMESPACE',
264 | kvKey: '{file}',
265 | },
266 | },
267 | {
268 | handlerName: 'transform',
269 | path: '/google/.*',
270 | options: {
271 | transforms: [
272 | {
273 | regex: 'google',
274 | replace: 'giggle',
275 | },
276 | ],
277 | },
278 | },
279 | {
280 | handlerName: 'loadbalancer',
281 | path: '/google/:file*',
282 | options: {
283 | sources: [
284 | {
285 | url: 'https://us-central1-ahlstrand-es.cloudfunctions.net/hello/{file}',
286 | },
287 | ],
288 | },
289 | },
290 | // {
291 | // handlerName: 'apiKey',
292 | // path: '/oauth2/.*',
293 | // options: {
294 | // oauth2ClientId: process.env.OAUTH2_CLIENT_ID,
295 | // oauth2ClientSecret: process.env.OAUTH2_CLIENT_SECRET,
296 | // oauth2AuthDomain: process.env.OAUTH2_AUTH_DOMAIN,
297 | // kvAccountId: process.env.KV_ACCOUNT_ID,
298 | // kvNamespace: process.env.KV_NAMESPACE,
299 | // kvAuthEmail: process.env.KV_AUTH_EMAIL,
300 | // kvAuthKey: process.env.KV_AUTH_KEY,
301 | // },
302 | // },
303 | {
304 | handlerName: 'oauth2',
305 | path: '/oauth2/.*',
306 | options: {
307 | oauth2ClientId: process.env.OAUTH2_CLIENT_ID,
308 | oauth2ClientSecret: process.env.OAUTH2_CLIENT_SECRET,
309 | oauth2AuthDomain: process.env.OAUTH2_AUTH_DOMAIN,
310 | oauth2Audience: process.env.OAUTH2_AUDIENCE,
311 | oauth2CallbackPath: '/oauth2/callback',
312 | oauth2LogoutPath: '/oauth2/logout',
313 | oauth2LoginPath: '/oauth2/login',
314 | oauth2Scopes: ['openid', 'email', 'profile', 'offline_access'],
315 | kvAccountId: process.env.KV_ACCOUNT_ID,
316 | kvNamespace: process.env.KV_NAMESPACE,
317 | kvAuthEmail: process.env.KV_AUTH_EMAIL,
318 | kvAuthKey: process.env.KV_AUTH_KEY,
319 | },
320 | },
321 | {
322 | handlerName: 'jwt',
323 | path: '/oauth2/.*',
324 | options: {
325 | jwksUri: process.env.JWKS_URI,
326 | },
327 | },
328 | {
329 | handlerName: 'response',
330 | path: '/oauth2/.*',
331 | options: {
332 | body: 'This is a secret messages protected by oauth2',
333 | },
334 | },
335 | {
336 | handlerName: 'origin',
337 | options: {
338 | localOriginOverride: 'https://static.ahlstrand.es',
339 | },
340 | },
341 | ];
342 |
343 | const proxy = new Proxy(rules, {
344 | custom: (options) => {
345 | return async (ctx) => {
346 | ctx.status = 200;
347 | ctx.body = 'Custom handler';
348 | };
349 | },
350 | });
351 |
352 | /**
353 | * Fetch and log a given request object
354 | * @param {Request} options
355 | */
356 | async function handler(event) {
357 | return proxy.resolve(event);
358 | }
359 |
360 | module.exports = handler;
361 |
--------------------------------------------------------------------------------
/src/handlers/oauth2.ts:
--------------------------------------------------------------------------------
1 | import cookie from 'cookie';
2 | import get from 'lodash.get';
3 | import set from 'lodash.set';
4 | import shortid from 'shortid';
5 | import KvStorage from '../services/kv-storage';
6 | import jwtRefresh from './jwt-refresh';
7 | import aes from '../encryption/aes';
8 |
9 | const _ = {
10 | get,
11 | set,
12 | };
13 |
14 | function getCookie({ cookieHeader = '', cookieName }) {
15 | const cookies = cookie.parse(cookieHeader);
16 | return cookies[cookieName];
17 | }
18 |
19 | /**
20 | * Very simplistic form serializer that works for this case but probably nothing else :)
21 | * @param {*} obj
22 | */
23 | function serializeFormData(obj) {
24 | return Object.keys(obj)
25 | .map((key) => `${key}=${encodeURIComponent(obj[key])}`)
26 | .join('&');
27 | }
28 |
29 | function isBrowser(accept = '') {
30 | return accept.split(',').indexOf('text/html') !== -1;
31 | }
32 |
33 | export default function oauth2Handler({
34 | cookieName = 'proxy',
35 | cookieHttpOnly = true,
36 | allowPublicAccess = false,
37 | kvAccountId,
38 | kvNamespace,
39 | kvAuthEmail,
40 | kvAuthKey,
41 | kvTtl = 2592000, // A month
42 | oauth2AuthDomain,
43 | oauth2ClientId,
44 | oauth2ClientSecret,
45 | oauth2Audience,
46 | oauth2Scopes = [],
47 | oauth2CallbackPath = '/callback',
48 | oauth2CallbackType = 'cookie',
49 | oauth2LogoutPath = '/logout',
50 | oauth2LoginPath = '/login',
51 | oauth2ServerTokenPath = '/oauth/token',
52 | oauth2ServerAuthorizePath = '',
53 | oauth2ServerLogoutPath,
54 | }) {
55 | const kvStorage = new KvStorage({
56 | accountId: kvAccountId,
57 | namespace: kvNamespace,
58 | authEmail: kvAuthEmail,
59 | authKey: kvAuthKey,
60 | ttl: kvTtl,
61 | });
62 |
63 | const authDomain = oauth2AuthDomain;
64 | const callbackPath = oauth2CallbackPath;
65 | const callbackType = oauth2CallbackType;
66 | const serverTokenPath = oauth2ServerTokenPath;
67 | const serverAuthorizePath = oauth2ServerAuthorizePath;
68 | const serverLogoutPath = oauth2ServerLogoutPath;
69 | const clientId = oauth2ClientId;
70 | const clientSecret = oauth2ClientSecret;
71 | const audience = oauth2Audience;
72 | const logoutPath = oauth2LogoutPath;
73 | const loginPath = oauth2LoginPath;
74 | const scopes = oauth2Scopes;
75 | const scope = scopes.join('%20');
76 |
77 | async function getTokenFromCode(code, redirectUrl) {
78 | const tokenUrl = `${authDomain}${serverTokenPath}`;
79 |
80 | const response = await fetch(tokenUrl, {
81 | method: 'POST',
82 | headers: {
83 | 'content-type': 'application/x-www-form-urlencoded',
84 | },
85 | body: serializeFormData({
86 | code,
87 | grant_type: 'authorization_code',
88 | client_id: clientId,
89 | client_secret: clientSecret,
90 | redirect_uri: redirectUrl,
91 | }),
92 | });
93 |
94 | if (!response.ok) {
95 | throw new Error('Authentication failed');
96 | }
97 |
98 | const body = await response.json();
99 |
100 | return {
101 | ...body,
102 | expires: Date.now() + body.expires_in * 1000,
103 | };
104 | }
105 |
106 | async function handleLogout(ctx) {
107 | const sessionCookie = getCookie({
108 | cookieHeader: ctx.request.headers.cookie,
109 | cookieName,
110 | });
111 |
112 | if (sessionCookie) {
113 | const domain = ctx.request.hostname.match(/[^.]+\.[^.]+$/i)[0];
114 |
115 | // Remove the cookie
116 | ctx.set(
117 | 'Set-Cookie',
118 | cookie.serialize(cookieName, '', {
119 | domain: `.${domain}`,
120 | path: '/',
121 | maxAge: 0,
122 | }),
123 | );
124 | }
125 |
126 | const returnToPath = getRedirectTo(ctx);
127 |
128 | if (oauth2ServerLogoutPath) {
129 | const returnTo = encodeURIComponent(
130 | `${ctx.request.protocol}://${ctx.request.host}${returnToPath}`,
131 | );
132 | // Bounce to remove cookie at the oauth server
133 | ctx.set(
134 | 'Location',
135 | `${authDomain}${serverLogoutPath}?client_id=${clientId}&returnTo=${returnTo}`,
136 | );
137 | } else {
138 | ctx.set('Location', returnToPath);
139 | }
140 |
141 | ctx.status = 302;
142 | }
143 |
144 | async function handleCallback(ctx) {
145 | const redirectUrl = ctx.request.href.split('?')[0];
146 |
147 | const body = await getTokenFromCode(ctx.request.query.code, redirectUrl);
148 |
149 | const key = shortid.generate();
150 | const salt = await aes.getSalt();
151 | const sessionToken = `${key}.${salt}`;
152 |
153 | const aesKey = await aes.deriveAesGcmKey(key, salt);
154 | const data = await aes.encrypt(aesKey, JSON.stringify(body));
155 |
156 | await kvStorage.put(key, data);
157 |
158 | const domain = ctx.request.hostname.match(/[^.]+\.[^.]+$/i)[0];
159 |
160 | ctx.status = 302;
161 |
162 | if (callbackType === 'query') {
163 | ctx.set('Location', `${ctx.request.query.state}?auth=${sessionToken}`);
164 | } else {
165 | ctx.set(
166 | 'Set-Cookie',
167 | cookie.serialize(cookieName, sessionToken, {
168 | httpOnly: cookieHttpOnly,
169 | domain: `.${domain}`,
170 | path: '/',
171 | maxAge: 60 * 60 * 24 * 365, // 1 year
172 | }),
173 | );
174 | ctx.set('Location', ctx.request.query.state);
175 | }
176 | }
177 |
178 | /**
179 | * Try to set a bearer based on the session cookie
180 | * @param {*} ctx
181 | * @param {*} sessionToken
182 | */
183 | async function getSession(ctx, sessionToken) {
184 | const [key, salt] = sessionToken.split('.');
185 | const data = await kvStorage.get(key);
186 |
187 | if (data) {
188 | const aesKey = await aes.deriveAesGcmKey(key, salt);
189 | const authData = await aes.decrypt(aesKey, data);
190 |
191 | let tokens = JSON.parse(authData);
192 |
193 | if (tokens.expires < Date.now()) {
194 | tokens = await jwtRefresh({
195 | refresh_token: tokens.refresh_token,
196 | clientId,
197 | authDomain,
198 | clientSecret,
199 | });
200 |
201 | const encryptedAuthData = await aes.encrypt(aesKey, JSON.stringify(tokens));
202 |
203 | await kvStorage.put(key, encryptedAuthData);
204 | }
205 |
206 | ctx.state.accessToken = tokens.access_token;
207 | if (ctx.state.accessToken) {
208 | ctx.request.headers.authorization = `bearer ${ctx.state.accessToken}`;
209 | }
210 | } else {
211 | // Remove the cookie if the session can't be found in the kv-store
212 | const domain = ctx.request.hostname.match(/[^.]+\.[^.]+$/i)[0];
213 | // Remove the cookie
214 | ctx.set(
215 | 'Set-Cookie',
216 | cookie.serialize(cookieName, '', {
217 | domain: `.${domain}`,
218 | maxAge: 0,
219 | }),
220 | );
221 | }
222 | }
223 |
224 | function getRedirectTo(ctx) {
225 | const redirectTo = _.get(ctx, 'request.query.redirect-to');
226 | if (redirectTo) {
227 | return redirectTo;
228 | }
229 |
230 | const referer = _.get(ctx, 'request.headers.referer');
231 | // TODO: Add a whitelist with regex
232 | if (referer) {
233 | return referer;
234 | }
235 |
236 | // Default to the root
237 | return '/';
238 | }
239 |
240 | /**
241 | * Explicitly logins a user
242 | * @param {*} ctx
243 | * @param {*} next
244 | */
245 | async function handleLogin(ctx) {
246 | // Options requests should return a 200
247 | if (ctx.request.method === 'OPTIONS') {
248 | ctx.status = 200;
249 | } else {
250 | const redirectTo = getRedirectTo(ctx);
251 |
252 | const state = encodeURIComponent(redirectTo || '/');
253 | const encodedRedirectUri = encodeURIComponent(
254 | `${ctx.request.protocol}://${ctx.request.host}${callbackPath}`,
255 | );
256 |
257 | ctx.status = 302;
258 | ctx.set(
259 | 'location',
260 | `${authDomain}${serverAuthorizePath}/authorize?state=${state}&client_id=${clientId}&response_type=code&scope=${scope}&audience=${audience}&redirect_uri=${encodedRedirectUri}`,
261 | );
262 | }
263 | }
264 |
265 | /**
266 | * Validates the request based on bearer token and cookie
267 | * @param {*} ctx
268 | * @param {*} next
269 | */
270 | async function handleValidate(ctx, next) {
271 | // Options requests should not be authenticated. Requests with auth headers are passed through
272 | if (ctx.request.method === 'OPTIONS') {
273 | await next(ctx);
274 | } else if (
275 | _.get(ctx, 'request.headers.authorization', '').toLowerCase().startsWith('bearer ')
276 | ) {
277 | // If the request has a auth-header, use this and pass on.
278 | _.set(ctx, 'state.access_token', ctx.request.headers.authorization.slice(7));
279 | await next(ctx);
280 | } else {
281 | // Check for the token in the querystring first and fallback to the cookie
282 | const sessionToken =
283 | _.get(ctx, 'request.query.auth') ||
284 | getCookie({
285 | cookieHeader: ctx.request.headers.cookie,
286 | cookieName,
287 | });
288 |
289 | // If the client didn't supply a bearer token, try to fetch one based on the cookie
290 | if (sessionToken) {
291 | await getSession(ctx, sessionToken);
292 | }
293 |
294 | const accessToken = _.get(ctx, 'state.accessToken');
295 |
296 | if (accessToken || allowPublicAccess) {
297 | await next(ctx);
298 | } else if (isBrowser(ctx.request.headers.accept)) {
299 | // For now we just code the requested url in the state. Could pass more properties in a serialized object
300 | const state = encodeURIComponent(ctx.request.href);
301 | const encodedRedirectUri = encodeURIComponent(
302 | `${ctx.request.protocol}://${ctx.request.host}${callbackPath}`,
303 | );
304 |
305 | ctx.status = 302;
306 | ctx.set(
307 | 'location',
308 | `${authDomain}${serverAuthorizePath}/authorize?state=${state}&client_id=${clientId}&response_type=code&scope=${scope}&audience=${audience}&redirect_uri=${encodedRedirectUri}`,
309 | );
310 | } else {
311 | ctx.status = 403;
312 | ctx.body = 'Forbidden';
313 | }
314 | }
315 | }
316 |
317 | return async (ctx, next) => {
318 | switch (ctx.request.path) {
319 | case callbackPath:
320 | await handleCallback(ctx);
321 | break;
322 | case logoutPath:
323 | await handleLogout(ctx);
324 | break;
325 | case loginPath:
326 | await handleLogin(ctx);
327 | break;
328 | default:
329 | await handleValidate(ctx, next);
330 | }
331 | };
332 | }
333 |
--------------------------------------------------------------------------------
/test/handlers/oauth2.test.ts:
--------------------------------------------------------------------------------
1 | import fetchMock from 'fetch-mock';
2 | import Oauth2Handler from '../../src/handlers/oauth2';
3 | import helpers from '../helpers';
4 |
5 | describe('oauth2Handler', () => {
6 | afterEach(() => {
7 | fetchMock.restore();
8 | });
9 |
10 | describe('login', () => {
11 | it('should redirect login requests to the login endpoint', async () => {
12 | const oauth2Handler = Oauth2Handler({
13 | oauth2AuthDomain: 'http://example.com',
14 | oauth2ClientId: '1234',
15 | oauth2Audience: 'test',
16 | });
17 |
18 | const ctx = helpers.getCtx();
19 | ctx.request.path = '/login';
20 |
21 | await oauth2Handler(ctx, helpers.getNext());
22 |
23 | expect(ctx.status).toBe(302);
24 | expect(ctx.response.headers.get('location')).toBe(
25 | 'http://example.com/authorize?state=%2F&client_id=1234&response_type=code&scope=&audience=test&redirect_uri=http%3A%2F%2Fexample.com%2Fcallback',
26 | );
27 | });
28 | });
29 |
30 | describe('callback', () => {
31 | it('should by default set a cookie and redirect back to the url in the state', async () => {
32 | const oauth2Handler = Oauth2Handler({
33 | oauth2AuthDomain: 'http://example.com',
34 | oauth2ClientId: '1234',
35 | oauth2Audience: 'test',
36 | kvNamespace: 'kvNamespace',
37 | kvAccountId: 'kvAccountId',
38 | });
39 |
40 | fetchMock.post('http://example.com/oauth/token', {
41 | access_token: '1234',
42 | refresh_token: '5678',
43 | expires_in: 100,
44 | });
45 | fetchMock.put(
46 | /https:\/\/api\.cloudflare\.com\/client\/v4\/accounts\/kvAccountId\/storage\/kv\/namespaces\/kvNamespace\/values\/.*/,
47 | 200,
48 | );
49 |
50 | const ctx = helpers.getCtx();
51 | ctx.request.path = '/callback';
52 | ctx.request.href = 'http://example.com/callback';
53 | ctx.request.query = {
54 | state: '/',
55 | code: '1234',
56 | };
57 |
58 | await oauth2Handler(ctx, helpers.getNext());
59 |
60 | expect(ctx.status).toBe(302);
61 | expect(ctx.response.headers.get('Location')).toBe('/');
62 | expect(typeof ctx.response.headers.get('Set-Cookie')).toBe('string');
63 | });
64 |
65 | it('should redirect back to the url in the state with an appended auth parameter if configured for querystring tokens', async () => {
66 | const oauth2Handler = Oauth2Handler({
67 | oauth2AuthDomain: 'http://example.com',
68 | oauth2ClientId: '1234',
69 | oauth2Audience: 'test',
70 | oauth2CallbackType: 'query',
71 | kvNamespace: 'kvNamespace',
72 | kvAccountId: 'kvAccountId',
73 | });
74 |
75 | fetchMock.post('http://example.com/oauth/token', {
76 | access_token: '1234',
77 | refresh_token: '5678',
78 | expires_in: 100,
79 | });
80 | fetchMock.put(
81 | /https:\/\/api\.cloudflare\.com\/client\/v4\/accounts\/kvAccountId\/storage\/kv\/namespaces\/kvNamespace\/values\/.*/,
82 | 200,
83 | );
84 |
85 | const ctx = helpers.getCtx();
86 | ctx.request.path = '/callback';
87 | ctx.request.href = 'http://example.com/callback';
88 | ctx.request.query = {
89 | state: '/',
90 | code: '1234',
91 | };
92 |
93 | await oauth2Handler(ctx, helpers.getNext());
94 |
95 | expect(ctx.status).toBe(302);
96 | expect(ctx.response.headers.get('Location').slice(0, 6)).toBe('/?auth');
97 | });
98 |
99 | it('should use the auth token from the querystring when validating', async () => {
100 | const oauth2Handler = Oauth2Handler({
101 | oauth2AuthDomain: 'http://example.com',
102 | oauth2ClientId: '1234',
103 | oauth2Audience: 'test',
104 | oauth2CallbackType: 'query',
105 | kvNamespace: 'kvNamespace',
106 | kvAccountId: 'kvAccountId',
107 | });
108 |
109 | fetchMock.get(
110 | /https:\/\/api\.cloudflare\.com\/client\/v4\/accounts\/kvAccountId\/storage\/kv\/namespaces\/kvNamespace\/values\/.*/,
111 | 'e1kFf2+TVXRKIYEgbX/iIQ3qiAIDcfVsXJn6pDgnO6d7Vqht3fOVN3LY6aNDOE9w+eEWJOBRn4xphWUWrjL7KdHEjF86EQDOHNIGwHWyYuyoxwhItQbsctARG327KTvFkXHHdUYCZ8qwrpoqonBhTvxefBDfSNDuVQ7pcqzyUaZHfjC3XiiR3YItYYtslQS0lJQlV+69qL/ltPWB1u88C1aItO8lFmFdzKAy7oK8/dC/Yi4VZFkoNnTUQBkujLBItDL4TtwIN6k0Ll8NOVa9P2nA+RxDEK1jQWuNmpRyjmtePIBsk1q7yvd+hekB2StogxQwsfJJbiA+22M6QWkTCK40VOS/ECQJ27ycQ8NbFR/n7+iiUcmJO0d0HafZnjHSE+i9j+89fBFIat5nTqfkeUOoDP0XLwbS8pnyp9H4v2bF0iCiSXT4WY4xrPjCRK8Df0r3MTV3Xmvrd+LvCRQKHcLLjS14g+v0gA5gXkNQhTSIHL7izSz1taFKro7hRu0ex/2a1xThHOTUZoT1bOuF5yX+KzP8jqoH7ADeWktCGWKr0Sk2mp3BaXyijU+P5mjLw9+WrTsGnzRYQYzBE+bZx1GqMaVNVaKNOQfGNH1MFRzqOytzSJpjRG7C7zWzHfH5R2wmVSSuJ1bR++xK6zeMT7YoBGBsDeDgElTXtKcrv+9aJnHWoFWij2zICFWJ1UnorRaOFUDxRAvSlbv6rKwEmORMQH4i30S7SoJBaGjW22/Dm9Mld+dF8Fg74yLRPw0G3/GrSzfd+BQzS+k4/qGAcae+3rmkoUqiy+J9LZDsFPhLv/1FZK4BxtXVmFcuINDlaMeKVHieeOAOcx2h+W34BXZ3AFFrUDgqrKSHRHPtTpD1ni9mBnIow4yucW09zKZTR4lwxWNt/tUF4WqghSH3t2Nwv1mn64gsAnv51p0o158qTrbQ1sVxCBLw3c7oOT2Dl9el6MEZO0t3BG8KhgRhUTlglTsZL3F7NfBEcBGaSFgbCznKvulwHT9bsHTi4fQNiHcXO2ee2KqJ11T25yzjibZh2fdEOYud7w2NfQroei6+h4cf9hDdOzjuEfE/RDYJDVR31gadj0EAt4eKXGHcIj8ztl9TwunvzkZEhawtAqT52L9WjarKLei9DS7JtUktJjLX7Gvp40mzgPulbvgSypiuNeP/JnVsS5b631WT9x3htlYfq+vflgisosafbfM6yCFRuZVJ3+TQDIjdh4+5k4QvdW4cUqur49mrLLerfiTlFZeeytNXjEcBmPNNUsjGDnwfTqaLmen/uCXcSBdA9AitmzqdkE0HD4/KJLP012mh3NeTervMJ8sYJ7MCjqUbVn0TmHANrgfgJGnIg2ccC6liGz4b6P0bC9D0+XOR1TUAyky/W3CohWFHsvpf9L99gex4Qf4tTaKICtLUXdXjIGJl+nJqTUBPuaXC7eTnK8qrbRsOqOOSuAZToi82jPpfxsnxC5n7y0Ck3J4O5ciSSFqaJVsHNYLQvYq+gMlIRNxJcicFR1aiD9Fay2XCjeiYVCRzanwIwxjFgqNkQTriMScg7Xahcfl8hrhharM/O26H72EDvpLt7g+vCnAcOsJv0Af3APr8Oku0N1tupcWsoT2i/VRxZyXjKYRzotWKRG6qgmmKRVo5IGSFGjGYSSXNX14bfsvkWXxTrs3Bza/oxu+JTQWyF7BJSKT7uxHsWpL6x924DVmX3qBU26DpXHN4oqfT7bCFYd6eAZ3aYjPcpQMkaT5Vv4FBhliU6QUwbzBviUBHIqla0gZQuVWutot3hLJBtcBDv6C0Qb929tKh4tcYu/IQ1g4+Of5lCrnyKKB9Wm1Q0llY/o8JfN8d3P2ntwruRUyLj1ie4PkCgTq6aqfGKCtD9V1S++FtsufptMSN07xSHBRTHTop8eyVLJQCUHnHrYEUJPSuqEnoN+W9Alq3/+yIcFJIWlBr4RPi8Vi0fjb0yGx070THs5plapaOYWUd1fIv1pWfAPxGSydJUTyuqOEZFxr5CzsmWr/gTbu1ieJ4rFIGTZHcWoa0Uv3EN1uvjBPlAThdMeh5KfMgpEW0GKSUxs3koHhTXQ3tiuZSSN9KZIGQ4OQG6l+gOqB3ax5Ooo4BECLhhRvj2qN4mKVOryEUi7GwxoCJciY6X9NDOXSDkUU735MwYMlq5bgA7angfmOx5q+UUYEXSqmlYgG0Ar7Jghc4e4GbMGGVgzF1DHEWmQMYXVdwAcaIeXHsYvbC2GFGfd+MS73Kto6gGZY6fht9SoAxMNw4eyZVcGZ+mO5Z69ebZDs1HjKEzdhfvJPYNMQr5bK6ePgu1D6ytQCiccmTykpNwY6wnYIkzejVTbBIL+vrwxeAhQgyZYRMzeA0t3TWX+Fs+cPBZ7JtxeDtqJP7faHSLmSas/f33dCL7v20ILHJbZ+mSZApDV/nhQKUxZp5LGE1brEmgTcFiq8YzZVdBHgO96hEd6ggdrP12d1iH97DIQu4w/T8vNUZIh8A6/kw3VBx9mp/Z8N4DeA9PZ3PPo73vEoQSybHLxheeEobGdn9avFoPb4JVsWJxmc1uLOhOIxYXZsvSFBt0ypiOJmC2k25E+HeM6no/SfVswpD28yH0bE13sdGcDQS+fDv+KrEvLaeMq9X5d8K0a98bSM8hRZLCUI0Q5O4fIQwqa2Wp3kUbH5I9z2Q92F+uB1dxIZytNokpNVbXk3/LPnroUH2EA53Vlgjud2wYUuRWJu7oaw4subs4Wy33Hg4l+n4YEdNjZ4ScP2GBwfxmV/1vuGkRa29/4JOva7ghGQdAEqwA/i03qqCRQ9jG10Pyuc28OkbDZcJeYgC8ZSuNQ4MnRnjjtG+eRgW5OjtbLzDm/Cmzz9tbynxEXaUXEQv7tThktpqobnvcvcaO52uFqPmdQdiHZD3E85zXHXJkIM3sSKzl4mvQ4d/lLCACD93JGhLrdHXoxscTlFLmdYIGQGMlNKmbuhOlxQCd0xX5tFmtckXILd1HozOj1AaqxRNw1wf+vr+wCd8Osv3r/cmB6Df0j1IB2VBMg9Er/MOH5LG0TBL1eT6yrCMlH77D4OT9afFPdVwylCrCTafJExaJCM6F/82+4ycSr13rksjXVMuczzXh91Z9qWPV6EY/eIl8ALzA/uFB/bURFN2ae6LW/6qW4QXIrDFK5WB3u6DAYnqeaTOU8ji40reHOE2ivwxWyc4nUpkNLBvYsN8qjsVSB0geFNcQ/fQ9INX5WSJIIxTqgtJR+/StgATRZiaYkZv4JGywW+k4YEJsV2jDxci+C7ZlrTRkI6SC0CMARzIzx0EMSHMqUuceg+d6ft3HnmSGLI5/iigNifYcRFutAPSr6kRLZqzUdFgmz0Sb9/KokaX8L19svqcQ/j2XGdOlNkWVJKNcVKuVHPkHjZeQHgF6d2uA/5gWjzf9eh3P9xoyLGgwKLq0yakEyzRR7KFi/nBsq7hR4/CTYpM0iMALc7+9UJWjD1mgGr0OLIpg3MPvU8I5SkKajXhHIfG8Vpe/lK7QEHfSTHwsk+FwozgfA9nUtNbX1eVIHWOYJdmeBHKyqB5n+K39mqi6EkSw6j9r06TWXmtYSvt/NXEsykbFq1VjLI/eMp19Rg1r57HOdeeZUgEMgYwK/wUFcmK8fqw9GiEVqaog4AuWZbG31pdX/d87bF3EhxqFS0ormk4KxBonfkE/vU+QSdcnX/QNkBtdcW4xXGvVdKbyukApDsRVpuvtg2f936Ot6lb4GkEOqJwRe8Xlddh7Iq/VPfWyo7aqK9469fSNqbQQiX2esoDCqv484R5kgvKFqL27UUjXobdNfvnmBOMaI2kES+NjY3gWLmsnaivhEcWv4kyOy9ZcwnXho4ps9p0bmhB5gtVd88UaNafAx2MdU9ilNVfdNAsLkljxXPeI2UNh0wgnKAlEgtnjv3tgSc69CjtJebBgRqqmu85Ma420P8qBwTI17e+zG2YEelszniucPxbKoRgVCTLA4qLl1FJDwYTXS0e0Jw0VzRxQYuw6TaiTCnZAQ/2dwCaxZP7E3XhU5ZMqv0VMLyZ7cEoxPgf9cEZkp6EE/vWpKTtTQlPVJ/AZw78PlSt/2qzSXU1st60AEE9k4nZkeyLqM5mWHYb6VP4B/rDF45ruEelyJ/Qsgg2jdJQ1ZN2VbJ3/USDaWMnKnwSWcRNgvORhNVbbxGcGtVQGd7l8czasJKOHZ8YX4sRLNLOameR4GmeEalp9+HO8yPu+aPyJorJyu5qIjqZ6kihXlIcNqtEa3HRgtRug4MzJPG+TmGWcPLU7dbkoVvqd9BWI7h7hKK/EgdZoiDnZeGjFsHG+e7FtWvNRC05148yAPsuc0yI6DN3xOVd4QIrbq41LUviJn75NUNQGM/mve9Fdvhcmrhrm593uaoz3RP1v2aSiRlN2x4VSZQu2kAD39w1wYrapeYdSyl1ttqsyIQhfEzsjjgHslfbyrmaJJjI5rZ8BVEZDyb0GUqKyXnbNiYvTm6TeNr4rxaJzKdEGowCzaKY9i3wKA+/cPZRn99hU0Gc8fKmfa9s/FgH7At7cmmnV2uJEoCc6wu3elCmZ6Sv627LEc5pLNMsZvo/JkCB6jDkiqdVsQLuAcHFBfJZE5EW+WyyAyAKZ47moxc3UFrpQ3x2ckW/u7pnmyaUDFYgT1dW3VCTBv848ErilcjiUI2Hg62xDYe71TA6+tDWlPEWPxodc3S/pEGHowW5Y13jnJLUzkDfTJLDVNzfWHx54LHBuhOhB3OLgo4/Hw5ZEBh8SkAR8n+bgT0MSlK7MMj7kxyDrsWO0qgFwxkAmdIpUFyGw0t/ZWBgfAuQXTUtGSDdyDLL3PXTT7e3P4mosyEbPEwM9AIdBMZLGlqa+mx838XQIyQsBJO0P04EU14/pMAOh91UCIS3E7mSq3vw1vYozrrunSQiuNvczEobmjW701ud4uIQ70+6EVXFIaq4FcBflkWhGWR0JvVNeYJzbkB8fC2PeubQJjPoFkQL7mReOrxZDkxFA/xbL6+VF+VMWMzz9EzTiVQK+sTB3sGo851GJLPumf1xdUQZSaCaBl48oNc5YMR5IA3h4Ma47HBt+V0A8hrTVGjb+W/4kYgreA5ZHIC3yC//192lNPfXHijNQJqoC+w8UiJjWYR64Gv4YYApfqvvCAG1PrAuoG7DeMBQjIDcdBrer2AVs/ILjqPNe/JjcVeZqrsEWFnHHwPWQp2+nRhwmU/xX6BxfzBEDF5F02UnA5HUSEC9lcKO0Lg/eF0l0CCjtov1UfqoP4t6FNRTXMBiZSXrqCF+skdr59SmtrLhyekFjt+YbH6TaxRQ9UGcgcajvC6u+sSBecfkz6SjhQ2fq8nGHoQ436nWqRoNkVkjaKaHSGTfkCLV89CaGpYvKaqOp4DJOE7Tu+4oL9lu7syAPmYKs3aNwJxn7ZJ3LBafZ8RNiyWrqOTPklHbqM/sSAyThDozjqsmw/4J80afN2k8yowGQEGMbFtr/GsT2vAYoXhr6qjP3S36iedS4UqNpSQHJzFHoEdrsBrg/KF0b82R9PU1w90zn5BM65hBLdQGmQUrMgzP7WDrS5Hld6HMhXWyAyu2q04EW+mA33s5lbyewWWIB4ZDLE/46v8exg8She53TZuf+eTVtBOfJ7TWVHvltrwP5o4MPbcKewiKSi4Fr6zUwYkUJ9G9/PR3zkyUiC1BSebMVKyVHAFRqzT/PTHTuWt08BZAP+kGx9+XlBqugXTIQWhcLJAL3x9i6B4ofomdiJHQ+tleR55NUo9hv3OcrkiS3+MPNEFJb7wqmTQ7GsSsUcfvwdqjPp1siD61a5IdCMZP',
112 | );
113 |
114 | const ctx = helpers.getCtx();
115 | ctx.request.path = '/test';
116 | ctx.request.href = 'http://example.com/test';
117 | ctx.request.query = {
118 | auth: '48CqIh02.hMyyX6WII',
119 | };
120 |
121 | await oauth2Handler(ctx, (ctx) => {
122 | ctx.status = 200;
123 | ctx.body = 'Hello world';
124 | });
125 |
126 | expect(ctx.status).toBe(200);
127 | });
128 |
129 | it('should use the auth token from headers', async () => {
130 | const oauth2Handler = Oauth2Handler({
131 | oauth2AuthDomain: 'http://example.com',
132 | oauth2ClientId: '1234',
133 | oauth2Audience: 'test',
134 | oauth2CallbackType: 'query',
135 | kvNamespace: 'kvNamespace',
136 | kvAccountId: 'kvAccountId',
137 | });
138 |
139 | const ctx = helpers.getCtx();
140 | ctx.request.path = '/test';
141 | ctx.request.href = 'http://example.com/test';
142 | ctx.request.query = {
143 | auth: 'should-not-be-used',
144 | };
145 | ctx.request.headers.authorization = 'Bearer header-token';
146 |
147 | await oauth2Handler(ctx, (ctx) => {
148 | ctx.status = 200;
149 | ctx.body = 'Hello world';
150 | expect(ctx.request.headers.authorization).toBe('Bearer header-token');
151 | });
152 |
153 | expect(ctx.status).toBe(200);
154 | });
155 | });
156 | });
157 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # cloudworker-proxy
2 |
3 | An api gateway for cloudflare workers with configurable handlers for:
4 |
5 | - Routing
6 | - Load balancing of http endpoints
7 | - Routing based on client Geo, host, path and protocol
8 | - Invoking AWS lambdas and google cloud functions
9 | - S3 buckets
10 | - Static responses from config or Cloudflare KV-Storage
11 | - Splitting requests to multiple endpoints
12 | - Logging (http, kinesis)
13 | - Authentication (basic, oauth2, signature)
14 | - Rate limiting
15 | - Caching
16 | - Rewrite
17 | - Modifying headers
18 | - Adding cors headers
19 | - Replacing or inserting content
20 |
21 | ## Installing
22 |
23 | Installing via NPM:
24 |
25 | ```
26 | npm install cloudworker-proxy --save
27 | ```
28 |
29 | ## Concept
30 |
31 | The proxy is a pipeline of different handlers that processes each request. The handlers in the pipeline could be:
32 |
33 | - Middleware. Such as logging or authentication that typically passes on the request further down the pipeline
34 | - Origins. Fetches content from other services, for instance using http.
35 | - Tranforms. Modifies the content before passing it back to the client
36 |
37 | Each handler can specify rules for which hosts and paths it should apply to, so it's possible to for instance only apply authentication to certain requests.
38 |
39 | The examples are deployed at https://proxy.cloudproxy.io
40 |
41 | ## Usage
42 |
43 | A proxy is instantiated with a set of middlewares, origins and transforms that are matched against each request based on hostname, method, path, protocol and headers. Each rule is configured to execute one of the predefined handlers. The handlers could either terminate the request and send the response to the client or pass on the request to the following handlers matching the request.
44 |
45 | A simple hello world proxy:
46 |
47 | ```
48 | const Proxy = require('cloudworker-proxy');
49 |
50 | const config = [{
51 | handlerName: "response",
52 | options: {
53 | body: "Hello world"
54 | }
55 | }];
56 |
57 | const proxy = new Proxy(config);
58 |
59 | async function fetchAndApply(event) {
60 | return await proxy.resolve(event);
61 | }
62 |
63 | addEventListener('fetch', (event) => {
64 | event.respondWith(fetchAndApply(event));
65 | });
66 |
67 | ```
68 |
69 | A handler can use path, method, host, protocol and headers to match a request. It's also possible to exclude certain paths from matching.
70 |
71 | The parameters from the request are resolved in the options, so simpler rewrites like this are possible:
72 |
73 | ```
74 | const config = [{
75 | path: "/hello/:name",
76 | excludePath: "/hello/markus",
77 | headers: {
78 | 'Accect': 'text/html',
79 | },
80 | protocol: 'https',
81 | method: ['GET', 'OPTIONS'],
82 | host: "example.com",
83 | handlerName: "response",
84 | options: {
85 | body: "Hello {name}"
86 | }
87 | }];
88 | ```
89 |
90 | ## Default Handlers
91 |
92 | ### Ratelimit
93 |
94 | Ratelimit the matching requests per minute per IP or for all clients.
95 |
96 | The ratelimit keeps the counters in memory so different edge nodes will have separate counters. For IP-based ratelimits it should work just fine as the requests from a client will hit the same edge node.
97 |
98 | The ratelimit can have different scopes, so a proxy can have multiple rate-limits for different endpoints.
99 |
100 | The ratelimit adds the following headers to the response object:
101 |
102 | - X-Ratelimit-Limit. This is the current limit being enforced
103 | - X-Ratelimit-Count. The current count of requests being made within the window
104 | - X-Ratelimit-Reset. The timeperiod in seconds until the rate limit is reset.
105 |
106 | HEAD and OPTIONS requests are not counted against the limit.
107 |
108 | An example of the configuration for ratelimit handler:
109 |
110 | ```
111 | rules = [{
112 | handlerName: 'rateLimit',
113 | options: {
114 | limit: 1000, // The default allowed calls
115 | scope: 'default',
116 | type: 'IP', // Anything except IP will sum up all calls
117 | }
118 | }];
119 | ```
120 |
121 | ### Logging
122 |
123 | The logging handler supports logging of requests and errors to http endpoints such as logz.io and AWS Kinesis.
124 |
125 | The logs are sent in chunks to the server. The chunks are sent when the predefined limit of messages are reached or after a certain time, whatever comes first.
126 |
127 | An example of configuration for a http logger:
128 |
129 | ```
130 | config = [{
131 | handlerName: 'logger',
132 | options: {
133 | type: 'http',
134 | url: process.env.LOGZ_URL,
135 | contentType: 'text/plain',
136 | delimiter: '_',
137 | },
138 | }];
139 | ```
140 |
141 | An example of configuration for a kinesis logger:
142 |
143 | ```
144 | config = [{
145 | handlerName: 'logger',
146 | options: {
147 | type: 'kinesis',
148 | region: 'us-east-1',
149 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
150 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
151 | streamName: 'cloudworker-proxy',
152 | },
153 | }];
154 | ```
155 |
156 | ### Basic Auth
157 |
158 | Uses basic auth to protect matching rules. The username and authTokens (base64-encoded versions of the passwords) are stored straight in the config which works fine for simple scenarios, but makes adding and removing users hard.
159 |
160 | An example of the configuration for the basic auth middleware:
161 |
162 | ```
163 | config = [{
164 | handlerName: 'basicAuth',
165 | path: '/basic',
166 | options: {
167 | users: [
168 | {
169 | username: 'test',
170 | authToken: 'dGVzdDpwYXNzd29yZA==', // "password" Base64 encoded
171 | }
172 | ],
173 | },
174 | }];
175 | ```
176 |
177 | ### Oauth2
178 |
179 | Logs in using standard oauth2 providers. So far tested with Auth0, AWS Cognito and Patreon but should work with any.
180 |
181 | It stores a session for each user in KV-storage and adds the access token as bearer to the context. The oauth2 handler does not validate the tokens, the validation is handled by the jwt-handler which typically is added after the oauth2-handler.
182 |
183 | The redirect back from the oauth2 flow sets a session cookie and stores the access and refresh tokens in KV-storage. By setting the oauth2CallbackType to query the session token will be added to the querystring instead.
184 |
185 | The handler by default automaticly redirect the client when it requests any matching resources. If login is optional the allowPublicAccess property can be set to true in which case the login needs to be explicitly triggered using the `oauth2LoginPath` which defaults to `/login`. The login endpoint takes a `redirectTo` query string parameter to determine where the user if redirected after the login flow.
186 |
187 | The handler supports the following options:
188 |
189 | - cookieName, the name of the cookie set by the handler. Defaults to 'proxy'
190 | - cookieHttpOnly, optional property to set if the cookies should be http only (https://owasp.org/www-community/HttpOnly). Defaults to true
191 | - allowPublicAccess, determines if any requests without valid cookies should be redirected to the login page. Defaults to true
192 | - kvAccountId, the account id for the KV storage account
193 | - kvNamespace, the namespace for the KV storage account
194 | - kvAuthEmail, the email for the KV storage account
195 | - kvAuthKey, the auth key for the KV storage account
196 | - kvTtl, the time to live for sessions in the KV storage account. The ttl is reset each time a new access token is fetched. Defaults to 2592000 which is roughly a month
197 | - oauth2AuthDomain, the base path for the oauth2 provider
198 | - oauthClientId, the oauth2 client id
199 | - oauth2ClientSecret, the oauth2 client secret
200 | - oauth2Audience, the oauth2 audience. This is optional for some providers
201 | - oauth2Scopes, the oauth2 scopes.
202 | - oauth2CallbackPath, the path for the callback to the proxy. Defaults to '/callback',
203 | - oauth2CallbackType, the way the sesion info is communicated back to the client. Can be set to 'cookie' or 'query'. Defaults to 'cookie',
204 | - oauth2LogoutPath, get requests to this url will causes the session to be closed. Defaults to '/logout',
205 | - oauth2LoginPath, a url for triggering a new login flow. Defaults to '/login',
206 | - oauth2ServerTokenPath, the path to the token endpoint on the oauth2 server. Defaults to '/oauth/token',
207 | - oauth2ServerAuthorizePath, the path for the authorize endpoint on the oauth2 server. Defaults to ''.
208 | - oauth2ServerLogoutPath, some oauth servers such as auth0 keeps the user logged in using a cookie. By specifying the path the browser will be bounced on the logout endpoint on the oauth provider.
209 |
210 | An example of the configuration for the oauth2 handler with auth0:
211 |
212 | ```
213 | config = [{
214 | handlerName: 'oauth2',
215 | path: '/.*',
216 | options: {
217 | oauth2ClientId: ,
218 | oauth2ClientSecret: ,
219 | oauth2AuthDomain: 'https://..auth0.com,
220 | oauth2CallbackPath: '/callback', // default value
221 | oauth2CallbackType: 'cookie', // default value
222 | oauth2LogoutPath: '/logout', // default value
223 | oauth2Scopes: ['openid', 'email', 'profile', 'offline_access'],
224 | oauth2ServerLogoutPath: '/v2/logout',
225 | kvAccountId: ,
226 | kvNamespace:
227 | kvAuthEmail: ,
228 | kvAuthKey:
229 | },
230 | }];
231 | ```
232 |
233 | An example of the configuration for the oauth2 handler with patreon:
234 |
235 | ```
236 | config = [ {
237 | handlerName: 'oauth2',
238 | path: '/.*',
239 | options: {
240 | oauthClientId: ,
241 | oauth2ClientSecret: ,
242 | oauth2AuthDomain: 'https://www.patreon.com,
243 | oauth2CallbackPath: '/callback', // default value
244 | oauth2CallbackType: 'cookie', // default value
245 | oauth2LogoutPath: '/logout', // default value
246 | oauth2ServerAuthorizePath: '/oauth2',
247 | oauth2ServerTokenPath: '/api/oauth2/token',
248 | oauth2Scopes: ["identity"],
249 | kvAccountId: ,
250 | kvNamespace:
251 | kvAuthEmail: ,
252 | kvAuthKey: ,
253 | },
254 | }];
255 | ```
256 |
257 | ### JWT
258 |
259 | The jwt handler validates any bearer tokens passed in the authencation headers.
260 |
261 | The handler base64 decodes the access token and adds it to the context state as a user object.
262 |
263 | An example of the configuration for the jwt handler:
264 |
265 | ```
266 | config = [ {
267 | handlerName: 'jwt',
268 | path: '/.*',
269 | options: {
270 | jwksUri: ,
271 | allowPublicAccess: false, // defaults to false
272 | },
273 | }];
274 | ```
275 |
276 | ### Signature
277 |
278 | Validates a hmac signature that should be available as a sign querystring parameter at the end of the url. If this parameter is not available or incorrect the handler will return a 403 error back to the client.
279 |
280 | The signature handler creates a signature based on the path so that a signed url will be valid even if the host changes. So if the `https://example.com/foo?bar=test` is signed, only the `/foo?bar=test` is signed and the result would be something like: `https://example.com/foo?bar=test&sign=4LQn8AjrvX6NogZ8KDEumw5UClOmE906WmE6vQZdwZU`
281 |
282 | An example of the configuration for the signature handler:
283 |
284 | ```
285 | config = [ {
286 | handlerName: 'signature',
287 | path: '/.*',
288 | options: {
289 | secret: 'shhhhh....'
290 | },
291 | }];
292 | ```
293 |
294 | The signature can be added in NodeJs using the following snippet:
295 |
296 | ```
297 | const nodeSignature = nodeCrypto
298 | .createHmac('SHA256', 'shhhhh....')
299 | .update('path')
300 | .digest('base64');
301 | ```
302 |
303 | ### Split
304 |
305 | Splits the request in two separate requests. The duplicated request will not return any results to the client, but can for instance be used to sample the traffic on a live website or to get webhooks to post to multiple endpoints.
306 |
307 | The split handler takes a host parameter that lets you route the requests to a different origin.
308 |
309 | An example of the configuration for the split handler:
310 |
311 | ```
312 | config = [{
313 | handlerName: 'split',
314 | options: {
315 | host: 'test.example.com',
316 | },
317 | }];
318 | ```
319 |
320 | ### Response
321 |
322 | Returns a static response to the request.
323 |
324 | The response handler is configured using a options object that contains the status, body and headers of the response. The body could either be a string or an object.
325 |
326 | An example of configuration for a response handler:
327 |
328 | ```
329 | const rules = [
330 | {
331 | handlerName: "response",
332 | options: {
333 | body: "Hello world",
334 | status: 200,
335 | headers: {
336 | 'Content-Type': 'text/html'
337 | }
338 | }
339 | }
340 | ];
341 | ```
342 |
343 | ### Kv-Storage
344 |
345 | The kv-storage handler serves static pages straight from kv-storage using the REST api.
346 | The kvKey property specifies which key is used to fetch the data from the key value store. It supports template variables which makes it possible to serve a complete static site with a single rule.
347 |
348 | There is a sample script in the script folder to push files to KV-storage.
349 |
350 | An example of configuration for a kv-storage handler:
351 |
352 | ```
353 | const rules = [
354 | {
355 | handlerName: "kvStorage",
356 | path: /kvStorage/:file*
357 | options: {
358 | kvAccountId: ,
359 | kvNamespace:
360 | kvAuthEmail: ,
361 | kvAuthKey: ,
362 | kvKey: '{file}', // Default value assuming that the path will use provide a file parameter
363 | kvBasePath: 'app/', // Fetches the files in the app folder in kv-storage
364 | defaultExtention: '', // The default value. Appends .html if no extention is specified on the file
365 | defaultIndexFile: null, // The file to fetch if the request is made to the root.
366 | defaultErrorFile: null, // The file to serve if the requested file can't be found in kv-storage
367 | }
368 | }
369 | ];
370 | ```
371 |
372 | It's possible to serve for instance a React app from kv-storage with the following config. The index.html file in the root will be served if a request is made to the root or to any other url where no file can be found in kv-storage.
373 |
374 | ```
375 | const rules = [
376 | {
377 | handlerName: "kvStorage",
378 | path: /kvStorage/:file*
379 | options: {
380 | kvAccountId: ,
381 | kvNamespace:
382 | kvAuthEmail: ,
383 | kvAuthKey: ,
384 | defaultIndexFile: 'index.html', // The file to fetch if the request is made to the root.
385 | defaultErrorFile: 'index.html', // The file to serve if the requested file can't be found in kv-storage
386 | }
387 | }
388 | ];
389 | ```
390 |
391 | ### Kv-Storage-Binding
392 |
393 | The kv-storage handler serves static pages straight from kv-storage using the in-worker javascript api. This should be slighly faster and should always fetch the data from the closest KV-storage node.
394 |
395 | The kvKey property specifies which key is used to fetch the data from the key value store. It supports template variables which makes it possible to serve a complete static site with a single rule.
396 |
397 | The in-worker api has support for fetching metadata as part of the Get-request which makes it possible to store Etags, Content-Type and other headers together with the data.
398 |
399 | There is a sample script in the script folder to push files to KV-storage.
400 |
401 | The handler requires a binding of the KV-Namespace which can be done by adding the following config to the serverless file:
402 |
403 | ```
404 | functions:
405 | cloudworker-proxy-examples:
406 | name: cloudworker-proxy-examples
407 | script: 'dist/bundle'
408 | webpack: false
409 | resources:
410 | kv:
411 | - variable: TEST_NAMESPACE
412 | namespace: test
413 | ```
414 |
415 | When running locally the node-cloudworker shim will make an additional request to the rest-api to fetch the metadata which should give the same result. For this to work the shim needs to be configured with the KV-Storage binding information:
416 |
417 | ```
418 | const ncw = require('node-cloudworker');
419 |
420 | ncw.applyShims({
421 | kv: {
422 | accountId: process.env.KV_ACCOUNT_ID,
423 | authEmail: process.env.KV_AUTH_EMAIL,
424 | authKey: process.env.KV_AUTH_KEY,
425 | bindings: [
426 | {
427 | variable: 'TEST_NAMESPACE',
428 | namespace: process.env.KV_NAMESPACE_TEST,
429 | },
430 | ],
431 | },
432 | });
433 | ```
434 |
435 | An example of configuration for a kv-storage handler:
436 |
437 | ```
438 | const rules = [
439 | {
440 | handlerName: "kvStorage",
441 | path: /kvStorage/:file*
442 | options: {
443 | kvAccountId: ,
444 | kvNamespaceBinding: 'TEST_NAMESPACE',
445 | kvKey: '{file}', // Default value assuming that the path will use provide a file parameter
446 | kvBasePath: 'app/', // Fetches the files in the app folder in kv-storage
447 | defaultExtention: '', // The default value. Appends .html if no extention is specified on the file
448 | defaultIndexFile: null, // The file to fetch if the request is made to the root.
449 | defaultErrorFile: null, // The file to serve if the requested file can't be found in kv-storage
450 | }
451 | }
452 | ];
453 | ```
454 |
455 | ### CORS
456 |
457 | Adds cross origin request headers for a path. The cors handler can optionally take an array of allowed origins to enable cors for.
458 |
459 | This is the default configuration for the cors handler
460 |
461 | ```
462 | config = [{
463 | handlerName: 'cors',
464 | options: {
465 | allowedOrigins = ['*'],
466 | allowedMethods = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
467 | allowCredentials = true,
468 | allowedHeaders = ['Content-Type'],
469 | allowedExposeHeaders = ['WWW-Authenticate', 'Server-Authorization'],
470 | maxAge = 600,
471 | optionsSuccessStatus = 204,
472 | terminatePreflight = false
473 | }
474 | }];
475 | ```
476 |
477 | - `allowedOrigins` - Controls [`Access-Control-Allow-Origin` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin). Array of allowed Origin domains, or a single item `['*']` if any Origin is allowed.
478 | - `allowedMethods` - Controls [`Access-Control-Allow-Methods` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods). Array of allowed methods. `['*']` is a valid value.
479 | - `allowCredentials` - Controls [`Access-Control-Allow-Credentials` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials). Boolean value.
480 | - `allowedHeaders` - Controls [`Access-Control-Allow-Headers` header]. Array of allowed request headers. `['*']` is a valid value.
481 | - `allowedExposeHeaders` - Controls [`Access-Control-Expose-Headers` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers). Array of allowed exposed headers. Set to `['*']` to allow exposing any headers. Set to `[]` to allow only the default 7 headers allowed by HTTP spec (see link).
482 | - `maxAge` - Constrols [`Access-Control-Max-Age` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age). Number of seconds that the CORS headers (`Access-Control-*`) should be cached by the client. Int value.
483 | - `terminatePreflight` - Set to true if you want `OPTIONS` requests to not be forwarded to origin.
484 | - `optionsSuccessStatus` - The HTTP status that should be returned in a preflight request. Only used if `terminatePreflight` is set to `true`
485 |
486 | ### Geo-decorator
487 |
488 | Adds a `proxy-continent` header to the request that can be used to route traffic from differnt continent differenty. The followin continents are available:
489 |
490 | - AF, africa
491 | - AN, antarctica
492 | - AS, asia
493 | - EU, europe
494 | - NA, north america
495 | - OC, oceania
496 | - SA, south america
497 |
498 | An example of the configuration for geo decoration handler in combination with a response handler targeting Europe:
499 |
500 | ```
501 | config = [{
502 | handlerName: 'geoDecorator',
503 | path: '/geo',
504 | options: {},
505 | },
506 | {
507 | handlerName: 'response',
508 | path: '/geo',
509 | headers: {
510 | 'proxy-continent': 'EU',
511 | },
512 | options: {
513 | body: 'This is served to clients in EU',
514 | },
515 | }];
516 | ```
517 |
518 | ### Cache
519 |
520 | Wraps the origin with cloudflare caching and works independent of what origin handler is used. It uses the caching headers set by the origin, but it's also possible to override the cache duration with the cacheDuration option.
521 |
522 | It is possible to define a custom cache key template. This makes it possible to vary the cache by for instance user-agent or posted body. The cache key template that can contain the following keys:
523 |
524 | - path
525 | - metod
526 | - header:
527 | - bodyHash
528 |
529 | This cache key template will cache seperate entries for requests with diferent origin headers:
530 | `{method}-{path}-{header:origin}`
531 |
532 | It is possible to remove certain headers from the cached response by using the headerBlacklist property. By default the following headers are remove from the cached response:
533 |
534 | - x-ratelimit-count
535 | - x-ratelimit-limit
536 | - x-ratelimit-reset
537 | - x-cache-hit
538 |
539 | The cache handler respects Range headers. It also support If-Modified-Since and If-None-Match headers to return 304's.
540 |
541 | An example of the configuration for cache handler in combination with a S3 handler:
542 |
543 | ```
544 | config = [{
545 | handlerName: 'cache',
546 | options: {
547 | cacheDuration: 60,
548 | headerBlacklist: ['x-my-header']
549 | },
550 | },
551 | {
552 | handlerName: 's3',
553 | path: '/:file',
554 | options: {
555 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
556 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
557 | region: 'eu-north-1',
558 | bucket: 'cloudproxy-test',
559 | path: '{file}',
560 | },
561 | }];
562 | ```
563 |
564 | This example would cache post requests to a query endpoint using a hash of the query bodys:
565 |
566 | ```
567 | config = [{
568 | handlerName: "cache",
569 | path: "/query",
570 | method: ["OPTIONS", "POST"],
571 | options: {
572 | cacheKeyTemplate: "{method}-{path}-{header:referer}-{bodyHash}",
573 | cacheDuration: 3600,
574 | },
575 | },
576 | {
577 | handlerName: "loadbalancer",
578 | path: "/query",
579 | method: ["OPTIONS", "POST"],
580 | options: {
581 | sources: [
582 | {
583 | url:
584 | "https://example.com/query",
585 | },
586 | ],
587 | },
588 | }]
589 | ```
590 |
591 | ### Load balancer
592 |
593 | Load balances requests between one or many endpoints.
594 |
595 | Currently the load balancer distributes the load between the endpoints randomly. Use cases for this handler are:
596 |
597 | - Load balance between multiple ingress servers in kubernetes
598 | - Route trafic to providers that doesn't support custom domains easliy, such as google cloud functions
599 | - Route trafic to cloud services with nested paths such as AWS Api Gateway or google cloud functions.
600 | - Route trafic to different endpoints and having flexibility to do updates without changing the origin servers.
601 |
602 | In some cases it is necessary to resolve the IP of the endpoint based on a different url than the host header. One example of this is when the load is distributed over multiple load balancer nodes that host multiple domains or subdomains. In these cases it's possible to set the resolveOverride on the load balancer handler. This way it will resolve the IP according to url property of the source, but use the resolveOverride as host header. NOTE: this is only possible if the host is hosted via the cloudflare cdn.
603 |
604 | An example of the configuration for loadbalancer with a single source on google cloud functions:
605 |
606 | ```
607 |
608 | config = [{
609 | handlerName: 'loadbalancer',
610 | options: {
611 | sources: [
612 | {
613 | url: 'https://europe-west1-ahlstrand.cloudfunctions.net/hello/{file}'
614 | }
615 | ]
616 | }
617 | }];
618 |
619 | ```
620 |
621 | An example of the configuration for loadbalancing traffic between two ingresses for multiple hosts, with an override of the host header:
622 |
623 | ```
624 | config = [{
625 | handlerName: 'loadbalancer',
626 | path: '/:file\*',
627 | options: {
628 | "resolveOverride": "www.ahlstrand.es",
629 | "sources": [
630 | {
631 | "url": "https://rancher-ingress-1.ahlstrand.es/{file}"
632 | },
633 | {
634 | "url": "https://rancher-ingress-2.ahlstrand.es/{file}"
635 | },
636 | ]
637 | }
638 | }];
639 |
640 | ```
641 |
642 | Using path and host parameters the handler can be more generic:
643 |
644 | ```
645 |
646 | config = [{
647 | handlerName: 'loadbalancer',
648 | path: '/:file\*',
649 | host: ':host.ahlstrand.es',
650 | options: {
651 | "resolveOverride": "{host}.ahlstrand.es",
652 | "sources": [
653 | {
654 | "url": "https://rancher-ingress-1.ahlstrand.es/{file}"
655 | },
656 | {
657 | "url": "https://rancher-ingress-2.ahlstrand.es/{file}"
658 | },
659 | ]
660 | }
661 | }];
662 |
663 | ```
664 |
665 | Requests made by the loadbalancer handler would not be cached when using standard fetch-calls as the source isn't proxied by cloudflare. Instead the handler uses the cache-api to manually store the response in the cloudflare cache. The responses are cached according to the cache-headers. If the ´cacheOverride`-option is added to the loadbalancer it will bypass the cache api and use standard fetch requests.
666 |
667 | ### Origin
668 |
669 | Passes the request to the origin for the cdn. This is typically used as a catch all handler to pass all requests that the worker shouldn't handle to origin.
670 |
671 | As this wouldn't work when running locall it's possible to specify another host name that will be used for debugging locally.
672 |
673 | An example of the configuration for the origin handler:
674 |
675 | ```
676 |
677 | config = [{
678 | handlerName: 'origin',
679 | options: {
680 | localOriginOverride: 'https://some.origin.com',
681 | }
682 | }];
683 |
684 | ```
685 |
686 | ### S3
687 |
688 | Fetches the files from a private S3 bucket using the AWS v4 signatures.
689 |
690 | An example of the configuration for the S3 handler:
691 |
692 | ```
693 | config = [{
694 | handlerName: 's3',
695 | path: '/:file*',
696 | options: {
697 | region: 'us-east-1',
698 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
699 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
700 | path: '{file}'
701 | }
702 | }];
703 |
704 | ```
705 |
706 | ### Lambda
707 |
708 | Invoke a AWS lambda using http without the AWS api gateway. The API Gateway from AWS is rather expensive for high load scenarios and using workers as a gateway is almost 10 times cheaper and much more flexible.
709 |
710 | An example of the configuration for the lambda handler:
711 |
712 | ```
713 |
714 | config = [{
715 | handlerName: 'lambda',
716 | options: {
717 | region: 'us-east-1',
718 | lambdaName: 'lambda-hello-dev-hello',
719 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
720 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
721 | },
722 | }];
723 |
724 | ```
725 |
726 | ### Transform
727 |
728 | The transform handler uses regexes to replace text in the responses. It can for instance be used to update relative paths in response from sources hosted on different domains or to insert scripts in web pages.
729 |
730 | The transformer in applied as a middleware and hence need to be added before the handler that fetches the data to transform.
731 |
732 | The replace parameter can take parameters from the regex match using `{{$0}}`, where the number is the index of the capturing group.
733 |
734 | The current implementation of tranforms have a few limitations:
735 |
736 | - If the size of the response get larger than about 5 MB it will use more cpu than the limit on cloudflare and fail.
737 | - If the string to be replaced is split between two chunks it won't currently work. The solution is likely to ensure that the chunks always contains complete rows which is sufficient for most cases.
738 |
739 | An example of the configuration for the origin handler:
740 |
741 | ```
742 |
743 | config = [{
744 | handlerName: 'tranform',
745 | options: {
746 | tranforms: [
747 | {
748 | regex: 'foo',
749 | replace: 'bar'
750 | }
751 | ]
752 | }
753 | }];
754 |
755 | ```
756 |
757 | ## Custom handlers
758 |
759 | It's possible to register custom handlers with new handler names or overriding default handlers by passing an object containing the handlers as second paramter of the proxy constructor:
760 |
761 | ```
762 | const proxy = new Proxy(rules, {
763 | custom: (options) => {
764 | return async (ctx) => {
765 | ctx.status = 200;
766 | ctx.body = 'Custom handler';
767 | };
768 | },
769 | });
770 |
771 | ```
772 |
773 | ## Security
774 |
775 | The handlers for oauth2 stores the encrypted tokens (AES-GCM) in KV-Storage. The key for the encryption is stored in the cookie so that both access to the storage and the cookie is needed to get any tokens.
776 |
777 | The tokens entries have a ttl of one month by default, so any token that hasn't been accessed in a month will automatically be removed.
778 |
779 | ## Examples
780 |
781 | For more examples of usage see the example folder which contains a complete solution deployed using serverless
782 |
--------------------------------------------------------------------------------