Skip to content

Commit

Permalink
feat(backend): Introduce buildRequestUrl utility and enable CLERK_TRU…
Browse files Browse the repository at this point in the history
…ST_HOST by default

* chore(nextjs): Trust Clerk host by default

* chore(nextjs): Drop using forwarded-port from clerkUrl

* feat(backend): Introduce buildRequestUrl utility

The buildRequestUrl utility will be used to construct the
request url using a request and optional path parameter
(used to overrided the path of the request url).
This utility will use the forwarded headers values and the
request.url to generate and return the actual request URL

* chore(shared): Depreacate getRequestUrl in favor of buildRequestUrl in backend

* chore(clerk-sdk-node): Use buildRequestUrl from backend package

* chore(nextjs): Use buildRequestUrl from backend package

* chore(remix): Use buildRequestUrl from backend package

* chore(remix): Drop createIsomorphicRequest usage since Remix request is already of type Request

* chore(nextjs): Add changeset

* chore(backend): Refactor existing cross-origin check - drop forwardedPort

* chore(backend): Add tests for buildRequestUrl and buildOrigin
  • Loading branch information
dimkl authored Jul 20, 2023
1 parent 808e45d commit 4ff4b71
Show file tree
Hide file tree
Showing 23 changed files with 242 additions and 182 deletions.
8 changes: 8 additions & 0 deletions .changeset/pretty-clouds-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-sdk-node': minor
'@clerk/backend': minor
'@clerk/nextjs': minor
'@clerk/remix': minor
---

Support hosting NextJs apps on non-Vercel platforms by constructing req.url using host-related headers instead of using on req.url directly. CLERK_TRUST_HOST is now enabled by default.
1 change: 1 addition & 0 deletions packages/backend/src/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default (QUnit: QUnit) => {
'Token',
'User',
'Verification',
'buildRequestUrl',
'constants',
'createAuthenticateRequest',
'createIsomorphicRequest',
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './tokens/jwt';
export * from './tokens/verify';
export { constants } from './constants';
export { redirect } from './redirections';
export { buildRequestUrl } from './utils';

export type ClerkOptions = CreateBackendApiOptions &
Partial<
Expand Down
7 changes: 3 additions & 4 deletions packages/backend/src/tokens/interstitialRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,13 @@ export const nonBrowserRequestInDevRule: InterstitialRule = options => {
};

export const crossOriginRequestWithoutHeader: InterstitialRule = options => {
const { origin, host, forwardedHost, forwardedPort, forwardedProto } = options;
const { origin, host, forwardedHost, forwardedProto } = options;
const isCrossOrigin =
origin &&
checkCrossOrigin({
originURL: new URL(origin),
host,
forwardedHost,
forwardedPort,
forwardedProto,
});

Expand Down Expand Up @@ -80,9 +79,9 @@ export const potentialFirstLoadInDevWhenUATMissing: InterstitialRule = options =
* It is expected that a primary app will trigger a redirect back to the satellite app.
*/
export const potentialRequestAfterSignInOrOutFromClerkHostedUiInDev: InterstitialRule = options => {
const { apiKey, secretKey, referrer, host, forwardedHost, forwardedPort, forwardedProto } = options;
const { apiKey, secretKey, referrer, host, forwardedHost, forwardedProto } = options;
const crossOriginReferrer =
referrer && checkCrossOrigin({ originURL: new URL(referrer), host, forwardedHost, forwardedPort, forwardedProto });
referrer && checkCrossOrigin({ originURL: new URL(referrer), host, forwardedHost, forwardedProto });
const key = secretKey || apiKey || '';

if (isDevelopmentFromApiKey(key) && crossOriginReferrer) {
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export default (QUnit: QUnit) => {
const requestState = await authenticateRequest({
...defaultMockAuthenticateRequestOptions,
origin: 'https://clerk.com',
forwardedProto: '80',
forwardedProto: 'http',
cookieToken: mockJwt,
});

Expand Down
22 changes: 9 additions & 13 deletions packages/backend/src/util/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ export default (QUnit: QUnit) => {
assert.true(checkCrossOrigin({ originURL, host, forwardedHost, forwardedProto }));
});

test('is CO when HTTPS to HTTP with forwarded port', assert => {
test('is CO when HTTPS to HTTP with forwarded proto', assert => {
const originURL = new URL('https://localhost');
const host = new URL('http://localhost').host;
const forwardedPort = '80';
const forwardedProto = 'http';

assert.true(checkCrossOrigin({ originURL, host, forwardedPort }));
assert.true(checkCrossOrigin({ originURL, host, forwardedProto }));
});

test('is CO with cross origin auth domain', assert => {
Expand All @@ -56,9 +56,8 @@ export default (QUnit: QUnit) => {

test('is CO when forwarded port overrides host derived port', assert => {
const originURL = new URL('https://localhost:443');
const host = new URL('https://localhost').host;
const forwardedPort = '3001';
assert.true(checkCrossOrigin({ originURL, host, forwardedPort }));
const host = new URL('https://localhost:3001').host;
assert.true(checkCrossOrigin({ originURL, host }));
});

test('is not CO with port included in x-forwarded host', assert => {
Expand All @@ -80,26 +79,23 @@ export default (QUnit: QUnit) => {
test('is not CO when forwarded port and origin does not contain a port - http', assert => {
const originURL = new URL('http://localhost');
const host = new URL('http://localhost').host;
const forwardedPort = '80';

assert.false(checkCrossOrigin({ originURL, host, forwardedPort }));
assert.false(checkCrossOrigin({ originURL, host }));
});

test('is not CO when forwarded port and origin does not contain a port - https', assert => {
const originURL = new URL('https://localhost');
const host = originURL.host;
const forwardedPort = '443';
const host = new URL('https://localhost').host;

assert.false(checkCrossOrigin({ originURL, host, forwardedPort }));
assert.false(checkCrossOrigin({ originURL, host }));
});

test('is not CO based on referrer with forwarded host & port and referer', assert => {
const host = '';
const forwardedPort = '80';
const forwardedHost = 'example.com';
const referrer = 'http://example.com/';

assert.false(checkCrossOrigin({ originURL: new URL(referrer), host, forwardedPort, forwardedHost }));
assert.false(checkCrossOrigin({ originURL: new URL(referrer), host, forwardedHost }));
});

test('is not CO for AWS', assert => {
Expand Down
56 changes: 3 additions & 53 deletions packages/backend/src/util/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildOrigin } from '../utils';
/**
* This function is only used in the case where:
* - DevOrStaging key is present
Expand All @@ -9,45 +10,15 @@ export function checkCrossOrigin({
originURL,
host,
forwardedHost,
forwardedPort,
forwardedProto,
}: {
originURL: URL;
host?: string | null;
forwardedHost?: string | null;
forwardedPort?: string | null;
forwardedProto?: string | null;
}) {
const fwdProto = getFirstValueFromHeaderValue(forwardedProto);
let fwdPort = getFirstValueFromHeaderValue(forwardedPort);

// If forwardedPort mismatch with forwardedProto determine forwardedPort
// from forwardedProto as fallback (if exists)
// This check fixes the Railway App issue
const fwdProtoHasMoreValuesThanFwdPorts =
(forwardedProto || '').split(',').length > (forwardedPort || '').split(',').length;
if (fwdProto && fwdProtoHasMoreValuesThanFwdPorts) {
fwdPort = getPortFromProtocol(fwdProto);
}

const originProtocol = getProtocolVerb(originURL.protocol);
if (fwdProto && fwdProto !== originProtocol) {
return true;
}

const protocol = fwdProto || originProtocol;
/* The forwarded host prioritised over host to be checked against the referrer. */
const finalURL = convertHostHeaderValueToURL(forwardedHost || host || undefined, protocol);
finalURL.port = fwdPort || finalURL.port;

if (getPort(finalURL) !== getPort(originURL)) {
return true;
}
if (finalURL.hostname !== originURL.hostname) {
return true;
}

return false;
const finalURL = buildOrigin({ forwardedProto, forwardedHost, protocol: originURL.protocol, host });
return finalURL && new URL(finalURL).origin !== originURL.origin;
}

export function convertHostHeaderValueToURL(host?: string, protocol = 'https'): URL {
Expand All @@ -58,27 +29,6 @@ export function convertHostHeaderValueToURL(host?: string, protocol = 'https'):
return new URL(`${protocol}://${host}`);
}

const PROTOCOL_TO_PORT_MAPPING: Record<string, string> = {
http: '80',
https: '443',
} as const;

function getPort(url: URL) {
return url.port || getPortFromProtocol(url.protocol);
}

function getPortFromProtocol(protocol: string) {
return PROTOCOL_TO_PORT_MAPPING[protocol];
}

function getFirstValueFromHeaderValue(value?: string | null) {
return value?.split(',')[0]?.trim() || '';
}

function getProtocolVerb(protocol: string) {
return protocol?.replace(/:$/, '') || '';
}

type ErrorFields = {
message: string;
long_message: string;
Expand Down
118 changes: 118 additions & 0 deletions packages/backend/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type QUnit from 'qunit';

import { buildOrigin, buildRequestUrl } from './utils';

export default (QUnit: QUnit) => {
const { module, test } = QUnit;

module('buildOrigin({ protocol, forwardedProto, forwardedHost, host })', () => {
test('without any param', assert => {
assert.equal(buildOrigin({}), '');
});

test('with protocol', assert => {
assert.equal(buildOrigin({ protocol: 'http' }), '');
});

test('with host', assert => {
assert.equal(buildOrigin({ host: 'localhost:3000' }), '');
});

test('with protocol and host', assert => {
assert.equal(buildOrigin({ protocol: 'http', host: 'localhost:3000' }), 'http://localhost:3000');
});

test('with forwarded proto', assert => {
assert.equal(
buildOrigin({ protocol: 'http', host: 'localhost:3000', forwardedProto: 'https' }),
'https://localhost:3000',
);
});

test('with forwarded proto - with multiple values', assert => {
assert.equal(
buildOrigin({ protocol: 'http', host: 'localhost:3000', forwardedProto: 'https,http' }),
'https://localhost:3000',
);
});

test('with forwarded host', assert => {
assert.equal(
buildOrigin({ protocol: 'http', host: 'localhost:3000', forwardedHost: 'example.com' }),
'http://example.com',
);
});

test('with forwarded host - with multiple values', assert => {
assert.equal(
buildOrigin({ protocol: 'http', host: 'localhost:3000', forwardedHost: 'example.com,example-2.com' }),
'http://example.com',
);
});

test('with forwarded proto and host', assert => {
assert.equal(
buildOrigin({
protocol: 'http',
host: 'localhost:3000',
forwardedProto: 'https',
forwardedHost: 'example.com',
}),
'https://example.com',
);
});

test('with forwarded proto and host - without protocol', assert => {
assert.equal(
buildOrigin({ host: 'localhost:3000', forwardedProto: 'https', forwardedHost: 'example.com' }),
'https://example.com',
);
});

test('with forwarded proto and host - without host', assert => {
assert.equal(
buildOrigin({ protocol: 'http', forwardedProto: 'https', forwardedHost: 'example.com' }),
'https://example.com',
);
});

test('with forwarded proto and host - without host and protocol', assert => {
assert.equal(buildOrigin({ forwardedProto: 'https', forwardedHost: 'example.com' }), 'https://example.com');
});
});

module('buildRequestUrl({ request, path })', () => {
test('without headers', assert => {
const req = new Request('http://localhost:3000/path');
assert.equal(buildRequestUrl(req), 'http://localhost:3000/path');
});

test('with forwarded proto / host headers', assert => {
const req = new Request('http://localhost:3000/path', {
headers: { 'x-forwarded-host': 'example.com', 'x-forwarded-proto': 'https,http' },
});
assert.equal(buildRequestUrl(req), 'https://example.com/path');
});

test('with forwarded proto / host and host headers', assert => {
const req = new Request('http://localhost:3000/path', {
headers: {
'x-forwarded-host': 'example.com',
'x-forwarded-proto': 'https,http',
host: 'example-host.com',
},
});
assert.equal(buildRequestUrl(req), 'https://example.com/path');
});

test('with path', assert => {
const req = new Request('http://localhost:3000/path');
assert.equal(buildRequestUrl(req, '/other-path'), 'http://localhost:3000/other-path');
});

test('with query params in request', assert => {
const req = new Request('http://localhost:3000/path');
assert.equal(buildRequestUrl(req), 'http://localhost:3000/path');
});
});
};
36 changes: 36 additions & 0 deletions packages/backend/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { constants } from './constants';

const getHeader = (req: Request, key: string) => req.headers.get(key);
const getFirstValueFromHeader = (value?: string | null) => value?.split(',')[0];

type BuildRequestUrl = (request: Request, path?: string) => URL;
export const buildRequestUrl: BuildRequestUrl = (request, path) => {
const initialUrl = new URL(request.url);

const forwardedProto = getHeader(request, constants.Headers.ForwardedProto);
const forwardedHost = getHeader(request, constants.Headers.ForwardedHost);
const host = getHeader(request, constants.Headers.Host);
const protocol = initialUrl.protocol;

const base = buildOrigin({ protocol, forwardedProto, forwardedHost, host: host || initialUrl.host });

return new URL(path || initialUrl.pathname, base);
};

type BuildOriginParams = {
protocol?: string;
forwardedProto?: string | null;
forwardedHost?: string | null;
host?: string | null;
};
type BuildOrigin = (params: BuildOriginParams) => string;
export const buildOrigin: BuildOrigin = ({ protocol, forwardedProto, forwardedHost, host }) => {
const resolvedHost = getFirstValueFromHeader(forwardedHost) ?? host;
const resolvedProtocol = getFirstValueFromHeader(forwardedProto) ?? protocol?.replace(/[:/]/, '');

if (!resolvedHost || !resolvedProtocol) {
return '';
}

return `${resolvedProtocol}://${resolvedHost}`;
};
2 changes: 2 additions & 0 deletions packages/backend/tests/suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import factoryTest from './dist/api/factory.test.js';
import exportsTest from './dist/exports.test.js';

import redirectTest from './dist/redirections.test.js';
import utilsTest from './dist/utils.test.js';

// Add them to the suite array
const suites = [
Expand All @@ -28,6 +29,7 @@ const suites = [
verifyJwtTest,
factoryTest,
redirectTest,
utilsTest,
];

export default suites;
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ exports[`/api public exports should not include a breaking change 1`] = `
"User",
"Verification",
"allowlistIdentifiers",
"buildRequestUrl",
"clerkClient",
"clients",
"constants",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ exports[`/edge-middleware public exports should not include a breaking change 1`
"User",
"Verification",
"allowlistIdentifiers",
"buildRequestUrl",
"clerkApi",
"clerkClient",
"clients",
Expand Down
Loading

0 comments on commit 4ff4b71

Please sign in to comment.