From 9d9aecac8efba9e88d04263060dc17991b103aad Mon Sep 17 00:00:00 2001 From: Forbes Lindesay Date: Tue, 21 Jul 2020 16:44:49 +0100 Subject: [PATCH] feat: add package for getting the full URL from a request --- docs/request-url.md | 85 ++++++++++++++++++++ packages/oauth1/src/index.ts | 13 +++- packages/oauth1/src/originalURL.ts | 36 --------- packages/oauth2/src/index.ts | 13 +++- packages/oauth2/src/originalURL.ts | 39 ---------- packages/passwordless/src/index.ts | 29 ++++--- packages/passwordless/src/originalURL.ts | 39 ---------- packages/passwordless/types.d.ts | 3 - packages/passwordless/types.js | 3 - packages/request-url/README.md | 3 + packages/request-url/package.json | 16 ++++ packages/request-url/src/index.ts | 99 ++++++++++++++++++++++++ website/sidebars.json | 2 +- 13 files changed, 242 insertions(+), 138 deletions(-) create mode 100644 docs/request-url.md delete mode 100644 packages/oauth1/src/originalURL.ts delete mode 100644 packages/oauth2/src/originalURL.ts delete mode 100644 packages/passwordless/src/originalURL.ts delete mode 100644 packages/passwordless/types.d.ts delete mode 100644 packages/passwordless/types.js create mode 100644 packages/request-url/README.md create mode 100644 packages/request-url/package.json create mode 100644 packages/request-url/src/index.ts diff --git a/docs/request-url.md b/docs/request-url.md new file mode 100644 index 0000000..f1d4383 --- /dev/null +++ b/docs/request-url.md @@ -0,0 +1,85 @@ +--- +title: Get Request URL +--- + +A small utility to get the original URL that a request was made with. e.g. if your user types `http://example.com/foo/bar` into the address bar, and it loads the web page on your server, this should return `http://example.com/foo/bar`. + +## Installation + +To install, run the following command in your terminal: + +``` +yarn add @authentication/request-url +``` + +## Usage + +Calling `getRequestURL` with either an express request or koa context returns the `URL` object representing the full URL that the visitor navigated to in order to load the page. + +```typescript +import getRequestURL from '@authentication/cloudflare-ip'; +import express from 'express'; + +const app = express(); + +app.use((req, res) => { + res.send(`The URL you requested is: ${getRequestURL(req).href}`); +}); + +app.listen(process.env.PORT || 3000); +``` + +```javascript +const getRequestURL = require('@authentication/cloudflare-ip'); +const express = require('express'); + +const app = express(); + +app.use((req, res) => { + res.send(`The URL you requested is: ${getRequestURL(req).href}`); +}); + +app.listen(process.env.PORT || 3000); +``` + +### Trust Proxy + +If you run your app behind a proxy in production, you will also need to pass `{trustProxy: true}`. `trustProxy` is enabled by default if `NODE_ENV=development` to make it easier to use `getRequestURL` with setups like webpack-dev-server. + +**N.B.** If a malicious user finds a way to bypass the proxy, or if the proxy does not overwrite the `x-forwarded-host` and `x-forwarded-proto` headers, it may be possible for a malicous attacker to force this function to return a URL for a server you do not control. + +### Base URL + +Instead of setting `trustProxy`, if you know the host name you expect your app to be running on, you should set the `baseURL` option. This is much more secure. You can use an environment variable to support different values between development, staging and production: + +```typescript +import getRequestURL from '@authentication/cloudflare-ip'; +import express from 'express'; + +const app = express(); + +app.use((req, res) => { + res.send(`The URL you requested is: ${getRequestURL(req, { + // set this variable to something like: https://www.example.com + baseURL: process.env.BASE_URL, + }).href}`); +}); + +app.listen(process.env.PORT || 3000); +``` + +```javascript +const getRequestURL = require('@authentication/cloudflare-ip'); +const express = require('express'); + +const app = express(); + +app.use((req, res) => { + res.send(`The URL you requested is: ${getRequestURL(req, { + // set this variable to something like: https://www.example.com + baseURL: process.env.BASE_URL, + }).href}`); +}); + +app.listen(process.env.PORT || 3000); +``` diff --git a/packages/oauth1/src/index.ts b/packages/oauth1/src/index.ts index 53ca29f..340c876 100644 --- a/packages/oauth1/src/index.ts +++ b/packages/oauth1/src/index.ts @@ -4,7 +4,7 @@ import Cookie from '@authentication/cookie'; import {Mixed} from '@authentication/types'; import AuthorizationError from './errors/AuthorizationError'; import StateVerificationFailure from './errors/StateVerificationFailure'; -import originalURL from './originalURL'; +import getRequestURL from '@authentication/request-url'; const OAuth1Base = require('oauth').OAuth; function parseURL(name: string, input: URL | string, base?: string | URL) { @@ -239,7 +239,14 @@ export default class OAuth1Authentication { typeof callbackURLInitial === 'string' ? new URL( callbackURLInitial, - originalURL(req, {trustProxy: this._trustProxy}), + getRequestURL(req, { + trustProxy: + this._trustProxy === undefined + ? req.app.get('trust proxy') || + process.env.NODE_ENV === 'development' + : this._trustProxy, + baseURL: process.env.BASE_URL || process.env.BASE_URI, + }), ) : callbackURLInitial; if (callbackURL) { @@ -261,7 +268,7 @@ export default class OAuth1Authentication { } const userAuthorizationParams = options.userAuthorizationParams; if (userAuthorizationParams) { - Object.keys(userAuthorizationParams).forEach(key => { + Object.keys(userAuthorizationParams).forEach((key) => { userAuthorizationURL.searchParams.set( key, userAuthorizationParams[key], diff --git a/packages/oauth1/src/originalURL.ts b/packages/oauth1/src/originalURL.ts deleted file mode 100644 index 938134b..0000000 --- a/packages/oauth1/src/originalURL.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {IncomingMessage} from 'http'; -import {URL} from 'url'; -/** - * Reconstructs the original URL of the request. - * - * This function builds a URL that corresponds the original URL requested by the - * client, including the protocol (http or https) and host. - * - * If the request passed through any proxies that terminate SSL, the - * `X-Forwarded-Proto` header is used to detect if the request was encrypted to - * the proxy, assuming that the proxy has been flagged as trusted. - */ -export default function originalURL( - req: IncomingMessage, - options: {trustProxy?: boolean | void | undefined} = {}, -): URL { - const app = (req as any).app; - if (app && app.get && app.get('trust proxy')) { - options.trustProxy = true; - } - const trustProxy = - typeof options.trustProxy === 'boolean' - ? options.trustProxy - : !!(app && app.get && app.get('trust proxy')) || - process.env.NODE_ENV === 'development'; - - const proto = ('' + (req.headers['x-forwarded-proto'] || '')).toLowerCase(); - const tls: boolean = - (req.connection as any).encrypted || - (trustProxy && 'https' == proto.split(/\s*,\s*/)[0]); - const host = - (trustProxy && req.headers['x-forwarded-host']) || req.headers.host; - const protocol = tls ? 'https' : 'http'; - const path = req.url || ''; - return new URL(protocol + '://' + host + path); -} diff --git a/packages/oauth2/src/index.ts b/packages/oauth2/src/index.ts index 4f8932f..630b0b6 100644 --- a/packages/oauth2/src/index.ts +++ b/packages/oauth2/src/index.ts @@ -8,7 +8,7 @@ import StateVerificationFailure from './errors/StateVerificationFailure'; import TokenError from './errors/TokenError'; import InternalOAuthError from './errors/InternalOAuthError'; import getUID from './getUID'; -import originalURL from './originalURL'; +import getRequestURL from '@authentication/request-url'; const OAuth2Base = require('oauth').OAuth2; // This type used to be exported from http but has gone missing @@ -261,7 +261,14 @@ export default class OAuth2Authentication { return typeof this._callbackURL === 'string' ? new URL( this._callbackURL, - originalURL(req, {trustProxy: this._trustProxy}), + getRequestURL(req, { + trustProxy: + this._trustProxy === undefined + ? req.app.get('trust proxy') || + process.env.NODE_ENV === 'development' + : this._trustProxy, + baseURL: process.env.BASE_URL || process.env.BASE_URI, + }), ) : this._callbackURL; } @@ -279,7 +286,7 @@ export default class OAuth2Authentication { const authorizeUrl = new URL(this._authorizeURL.href); const p = options.params; if (p) { - Object.keys(p).forEach(key => { + Object.keys(p).forEach((key) => { authorizeUrl.searchParams.set(key, p[key]); }); } diff --git a/packages/oauth2/src/originalURL.ts b/packages/oauth2/src/originalURL.ts deleted file mode 100644 index be14e65..0000000 --- a/packages/oauth2/src/originalURL.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {IncomingMessage} from 'http'; -import {URL} from 'url'; -/** - * Reconstructs the original URL of the request. - * - * This function builds a URL that corresponds the original URL requested by the - * client, including the protocol (http or https) and host. - * - * If the request passed through any proxies that terminate SSL, the - * `X-Forwarded-Proto` header is used to detect if the request was encrypted to - * the proxy, assuming that the proxy has been flagged as trusted. - */ -export default function originalURL( - req: IncomingMessage, - options: {trustProxy?: boolean | void | undefined} = {}, -): URL { - const app = (req as any).app; - if (app && app.get && app.get('trust proxy')) { - options.trustProxy = true; - } - const trustProxy = - typeof options.trustProxy === 'boolean' - ? options.trustProxy - : !!(app && app.get && app.get('trust proxy')) || - process.env.NODE_ENV === 'development'; - - const proto = ('' + (req.headers['x-forwarded-proto'] || '')).toLowerCase(); - const tls: boolean = - (req.connection as any).encrypted || - (trustProxy && 'https' == proto.split(/\s*,\s*/)[0]); - const host = - (trustProxy && req.headers['x-forwarded-host']) || req.headers.host; - const protocol = tls ? 'https' : 'http'; - const path = req.url || ''; - if (process.env.BASE_URL || process.env.BASE_URI) { - return new URL(path, process.env.BASE_URL || process.env.BASE_URI); - } - return new URL(protocol + '://' + host + path); -} diff --git a/packages/passwordless/src/index.ts b/packages/passwordless/src/index.ts index 34448b5..d53d5ae 100644 --- a/packages/passwordless/src/index.ts +++ b/packages/passwordless/src/index.ts @@ -24,7 +24,7 @@ import Store, { StoreTransaction, } from './Store'; import Token from './Token'; -import originalURL from './originalURL'; +import getRequestURL from '@authentication/request-url'; import { CreateTokenStatusKind, @@ -173,8 +173,8 @@ export default class PasswordlessAuthentication { options.maxAge === undefined ? ms('1 hour') : typeof options.maxAge === 'number' - ? options.maxAge - : ms(options.maxAge); + ? options.maxAge + : ms(options.maxAge); if ( typeof this._maxAge !== 'number' || isNaN(this._maxAge) || @@ -210,7 +210,7 @@ export default class PasswordlessAuthentication { // from behind a single router, but over the long run, we want to keep // this pretty low or someone could be quite abusive. this._createTokenByIpRateLimit = new BucketRateLimit( - this._getStore(ip => 'create_ip_' + ip), + this._getStore((ip) => 'create_ip_' + ip), { interval: '10 minutes', maxSize: 20, @@ -223,7 +223,7 @@ export default class PasswordlessAuthentication { // a few attempts. It is possible a user might get spammed with a // few token e-mails, but this will quickly stem the tide. this._createTokenByUserRateLimit = new ExponentialRateLimit( - this._getStore(userID => 'user_' + userID), + this._getStore((userID) => 'user_' + userID), { baseDelay: '5 minutes', factor: 2, @@ -235,7 +235,7 @@ export default class PasswordlessAuthentication { // We don't use an exponential backoff because resetting it when a // correct token attempt happens would defeat the point. this._validatePassCodeByIpRateLimit = new BucketRateLimit( - this._getStore(ip => 'validate_ip_' + ip), + this._getStore((ip) => 'validate_ip_' + ip), { interval: '10 minutes', maxSize: 20, @@ -253,7 +253,14 @@ export default class PasswordlessAuthentication { return typeof this._callbackURL === 'string' ? new URL( this._callbackURL, - originalURL(req, {trustProxy: this._trustProxy}), + getRequestURL(req, { + trustProxy: + this._trustProxy === undefined + ? req.app.get('trust proxy') || + process.env.NODE_ENV === 'development' + : this._trustProxy, + baseURL: process.env.BASE_URL || process.env.BASE_URI, + }), ) : new URL(this._callbackURL.href); } @@ -265,8 +272,8 @@ export default class PasswordlessAuthentication { } private _getStore(idToString: (id: T) => string): RateLimitStoreAPI { return { - tx: fn => - this._store.tx(store => + tx: (fn) => + this._store.tx((store) => fn({ save(id, state, oldState) { return store.saveRateLimit(idToString(id), state, oldState); @@ -323,7 +330,7 @@ export default class PasswordlessAuthentication { ]); const passCodeHash = await hash(passCode); // store the token - const tokenID = await this._store.tx(store => + const tokenID = await this._store.tx((store) => store.saveToken({ userID, dos: dosCode, @@ -446,7 +453,7 @@ export default class PasswordlessAuthentication { (await verify( passCode, token.passCodeHash, - async updatedPassCodeHash => { + async (updatedPassCodeHash) => { // we're about to delete the token anyway, // so no need to update it }, diff --git a/packages/passwordless/src/originalURL.ts b/packages/passwordless/src/originalURL.ts deleted file mode 100644 index be14e65..0000000 --- a/packages/passwordless/src/originalURL.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {IncomingMessage} from 'http'; -import {URL} from 'url'; -/** - * Reconstructs the original URL of the request. - * - * This function builds a URL that corresponds the original URL requested by the - * client, including the protocol (http or https) and host. - * - * If the request passed through any proxies that terminate SSL, the - * `X-Forwarded-Proto` header is used to detect if the request was encrypted to - * the proxy, assuming that the proxy has been flagged as trusted. - */ -export default function originalURL( - req: IncomingMessage, - options: {trustProxy?: boolean | void | undefined} = {}, -): URL { - const app = (req as any).app; - if (app && app.get && app.get('trust proxy')) { - options.trustProxy = true; - } - const trustProxy = - typeof options.trustProxy === 'boolean' - ? options.trustProxy - : !!(app && app.get && app.get('trust proxy')) || - process.env.NODE_ENV === 'development'; - - const proto = ('' + (req.headers['x-forwarded-proto'] || '')).toLowerCase(); - const tls: boolean = - (req.connection as any).encrypted || - (trustProxy && 'https' == proto.split(/\s*,\s*/)[0]); - const host = - (trustProxy && req.headers['x-forwarded-host']) || req.headers.host; - const protocol = tls ? 'https' : 'http'; - const path = req.url || ''; - if (process.env.BASE_URL || process.env.BASE_URI) { - return new URL(path, process.env.BASE_URL || process.env.BASE_URI); - } - return new URL(protocol + '://' + host + path); -} diff --git a/packages/passwordless/types.d.ts b/packages/passwordless/types.d.ts deleted file mode 100644 index 3e32db2..0000000 --- a/packages/passwordless/types.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -// @autogenerated - -export * from './lib/types'; \ No newline at end of file diff --git a/packages/passwordless/types.js b/packages/passwordless/types.js deleted file mode 100644 index 88f9523..0000000 --- a/packages/passwordless/types.js +++ /dev/null @@ -1,3 +0,0 @@ -// @autogenerated - -module.exports = require('./lib/types'); \ No newline at end of file diff --git a/packages/request-url/README.md b/packages/request-url/README.md new file mode 100644 index 0000000..e929a05 --- /dev/null +++ b/packages/request-url/README.md @@ -0,0 +1,3 @@ +# @authentication/request-url + +For documentation, see https://www.atauthentication.com/docs/request-url.html \ No newline at end of file diff --git a/packages/request-url/package.json b/packages/request-url/package.json new file mode 100644 index 0000000..3fabb15 --- /dev/null +++ b/packages/request-url/package.json @@ -0,0 +1,16 @@ +{ + "name": "@authentication/request-url", + "version": "0.0.0", + "description": "", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "dependencies": {}, + "scripts": {}, + "repository": "https://github.com/ForbesLindesay/authentication/tree/master/packages/request-url", + "bugs": "https://github.com/ForbesLindesay/authentication/issues", + "license": "GPL-3.0", + "publishConfig": { + "access": "public" + }, + "homepage": "https://www.atauthentication.com/docs/request-url.html" +} diff --git a/packages/request-url/src/index.ts b/packages/request-url/src/index.ts new file mode 100644 index 0000000..bdb6ef7 --- /dev/null +++ b/packages/request-url/src/index.ts @@ -0,0 +1,99 @@ +import {URL} from 'url'; + +/** + * Reconstructs the original URL of the request. + * + * This function builds a URL that corresponds the original URL requested by the + * client, including the protocol (http or https) and host. + * + * If the request passed through any proxies that terminate SSL, the + * `X-Forwarded-Proto` header is used to detect if the request was encrypted to + * the proxy, assuming that the proxy has been flagged as trusted. + */ +export default function getRequestURL( + expressRequestOrKoaContext: { + readonly headers?: unknown; + readonly url?: string; + readonly connection?: unknown; + }, + options: { + trustProxy?: boolean | void | undefined; + baseURL?: URL | string; + } = {}, +): URL { + const path = expressRequestOrKoaContext.url || ''; + if (options.baseURL) { + const result = new URL(path, options.baseURL); + if ( + !result.href.startsWith( + typeof options.baseURL === 'string' + ? options.baseURL + : options.baseURL.href, + ) + ) { + throw new Error( + 'The url should start with the base URL. Either baseURL or req.url is invalid.', + ); + } + return result; + } + + const trustProxy = + typeof options.trustProxy === 'boolean' + ? options.trustProxy + : process.env.NODE_ENV === 'development'; + + const tls: boolean = + safeGetBoolean(expressRequestOrKoaContext.connection, 'encrypted', false) || + (trustProxy && + 'https' === + safeGetString(expressRequestOrKoaContext.headers, 'x-forwarded-proto') + .toLowerCase() + .split(/\s*,\s*/)[0]); + + const host = + (trustProxy && + safeGetString(expressRequestOrKoaContext.headers, 'x-forwarded-host')) || + safeGetString(expressRequestOrKoaContext.headers, 'host'); + + if (!host) { + throw new Error('Unable to determine the host for this request.'); + } + + const protocol = tls ? 'https' : 'http'; + const result = new URL(protocol + '://' + host + path); + + if (!result.href.startsWith(protocol + '://' + host)) { + throw new Error( + 'The url should start with the base URL. Either baseURL or req.url is invalid.', + ); + } + + return result; +} + +module.exports = Object.assign(getRequestURL, {default: getRequestURL}); + +function safeGetString(obj: unknown, key: string): string { + if (obj && typeof obj === 'object') { + return `${(obj as any)[key]}`; + } else { + return ''; + } +} + +function safeGetBoolean( + obj: unknown, + key: string, + defaultValue: boolean, +): boolean { + if ( + obj && + typeof obj === 'object' && + typeof (obj as any)[key] === 'boolean' + ) { + return (obj as any)[key]; + } else { + return defaultValue; + } +} diff --git a/website/sidebars.json b/website/sidebars.json index f9750e7..de2da5a 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -1,7 +1,7 @@ { "docs": { "Guides": ["getting-started", "profile"], - "Packages": ["csrf-protection", "cookie", "cookie-session", "send-message", "generate-passcode", "rate-limit", "secure-hash", "cloudflare-ip"], + "Packages": ["csrf-protection", "cookie", "cookie-session", "send-message", "generate-passcode", "rate-limit", "secure-hash", "cloudflare-ip", "request-url"], "Common Providers": ["passwordless", "facebook", "github", "google", "google-authenticator", "stripe", "tumblr"] } }