├── 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 | --------------------------------------------------------------------------------