diff --git a/packages/server/src/utils/routeUtils.test.ts b/packages/server/src/utils/routeUtils.test.ts index 59db01120f..bbd48add66 100644 --- a/packages/server/src/utils/routeUtils.test.ts +++ b/packages/server/src/utils/routeUtils.test.ts @@ -1,4 +1,4 @@ -import { parseSubPath, splitItemPath } from './routeUtils'; +import { isValidOrigin, parseSubPath, splitItemPath } from './routeUtils'; import { ItemAddressingType } from '../db'; describe('routeUtils', function() { @@ -41,4 +41,46 @@ describe('routeUtils', function() { } }); + it('should check the request origin', async function() { + const testCases: any[] = [ + [ + 'https://example.com', // Request origin + 'https://example.com', // Config base URL + true, + ], + [ + // Apache ProxyPreserveHost somehow converts https:// to http:// + // but in this context it's valid as only the domain matters. + 'http://example.com', + 'https://example.com', + true, + ], + [ + // With Apache ProxyPreserveHost, the request might be eg + // https://example.com/joplin/api/ping but the origin part, as + // forwarded by Apache will be https://example.com/api/ping + // (without /joplin). In that case the request is valid anyway + // since we only care about the domain. + 'https://example.com', + 'https://example.com/joplin', + true, + ], + [ + 'https://bad.com', + 'https://example.com', + false, + ], + [ + 'http://bad.com', + 'https://example.com', + false, + ], + ]; + + for (const testCase of testCases) { + const [requestOrigin, configBaseUrl, expected] = testCase; + expect(isValidOrigin(requestOrigin, configBaseUrl)).toBe(expected); + } + }); + }); diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index 5a856f5e27..6d9b9a758d 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -3,6 +3,7 @@ import { Item, ItemAddressingType } from '../db'; import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors'; import Router from './Router'; import { AppContext, HttpMethod } from './types'; +import { URL } from 'url'; const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils'); @@ -152,6 +153,12 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null return output; } +export function isValidOrigin(requestOrigin: string, endPointBaseUrl: string): boolean { + const host1 = (new URL(requestOrigin)).host; + const host2 = (new URL(endPointBaseUrl)).host; + return host1 === host2; +} + export function routeResponseFormat(context: AppContext): RouteResponseFormat { // const rawPath = context.path; // if (match && match.route.responseFormat) return match.route.responseFormat; @@ -168,7 +175,7 @@ export async function execRequest(routes: Routers, ctx: AppContext) { if (!match) throw new ErrorNotFound(); const endPoint = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema); - if (ctx.URL && ctx.URL.origin !== baseUrl(endPoint.type)) throw new ErrorNotFound('Invalid origin', 'invalidOrigin'); + if (ctx.URL && !isValidOrigin(ctx.URL.origin, baseUrl(endPoint.type))) throw new ErrorNotFound('Invalid origin', 'invalidOrigin'); // This is a generic catch-all for all private end points - if we // couldn't get a valid session, we exit now. Individual end points