Add function to allow hosts from environment variables (#287)

* Add allowed host option to handle CORS requests
* Update readme with new GRIST_ALLOWED_HOSTS environment variable
This commit is contained in:
Louis Delbosc 2022-09-28 18:33:53 +02:00 committed by GitHub
parent 9e681677a3
commit 49b1749e98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 39 additions and 7 deletions

View File

@ -202,6 +202,7 @@ APP_STATIC_URL | url prefix for static resources
APP_STATIC_INCLUDE_CUSTOM_CSS | set to "true" to include custom.css (from APP_STATIC_URL) in static pages APP_STATIC_INCLUDE_CUSTOM_CSS | set to "true" to include custom.css (from APP_STATIC_URL) in static pages
APP_UNTRUSTED_URL | URL at which to serve/expect plugin content. APP_UNTRUSTED_URL | URL at which to serve/expect plugin content.
GRIST_ADAPT_DOMAIN | set to "true" to support multiple base domains (careful, host header should be trustworthy) GRIST_ADAPT_DOMAIN | set to "true" to support multiple base domains (careful, host header should be trustworthy)
GRIST_ALLOWED_HOSTS | comma-separated list of permitted domains origin for requests (e.g. my.site,another.com)
GRIST_APP_ROOT | directory containing Grist sandbox and assets (specifically the sandbox and static subdirectories). GRIST_APP_ROOT | directory containing Grist sandbox and assets (specifically the sandbox and static subdirectories).
GRIST_BACKUP_DELAY_SECS | wait this long after a doc change before making a backup GRIST_BACKUP_DELAY_SECS | wait this long after a doc change before making a backup
GRIST_DATA_DIR | directory in which to store document caches. GRIST_DATA_DIR | directory in which to store document caches.

View File

@ -18,7 +18,7 @@ import {makeId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {IPermitStore, Permit} from 'app/server/lib/Permit'; import {IPermitStore, Permit} from 'app/server/lib/Permit';
import {AccessTokenInfo} from 'app/server/lib/AccessTokens'; import {AccessTokenInfo} from 'app/server/lib/AccessTokens';
import {allowHost, getOriginUrl, optStringParam} from 'app/server/lib/requestUtils'; import {allowHost, isEnvironmentAllowedHost, getOriginUrl, optStringParam} from 'app/server/lib/requestUtils';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import {NextFunction, Request, RequestHandler, Response} from 'express'; import {NextFunction, Request, RequestHandler, Response} from 'express';
import {IncomingMessage} from 'http'; import {IncomingMessage} from 'http';
@ -263,7 +263,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
// custom-domain owner could hijack such sessions. // custom-domain owner could hijack such sessions.
const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID); const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID);
if (allowedOrg) { if (allowedOrg) {
if (allowHost(req, allowedOrg.host)) { if (allowHost(req, allowedOrg.host) || isEnvironmentAllowedHost(allowedOrg.host)) {
customHostSession = ` custom-host-match ${allowedOrg.host}`; customHostSession = ` custom-host-match ${allowedOrg.host}`;
} else { } else {
// We need an exception for internal forwarding from home server to doc-workers. These use // We need an exception for internal forwarding from home server to doc-workers. These use

View File

@ -14,6 +14,7 @@ import {promisifyAll} from 'bluebird';
import * as _ from 'lodash'; import * as _ from 'lodash';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import {createClient, Multi, RedisClient} from 'redis'; import {createClient, Multi, RedisClient} from 'redis';
import {matchesBaseDomain} from 'app/server/lib/requestUtils';
promisifyAll(RedisClient.prototype); promisifyAll(RedisClient.prototype);
@ -550,6 +551,6 @@ export function isUrlAllowed(urlString: string) {
} }
return (process.env.ALLOWED_WEBHOOK_DOMAINS || "").split(",").some(domain => return (process.env.ALLOWED_WEBHOOK_DOMAINS || "").split(",").some(domain =>
domain && url.host.endsWith(domain) domain && matchesBaseDomain(url.host, domain)
); );
} }

View File

@ -9,6 +9,7 @@ import log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit'; import {Permit} from 'app/server/lib/Permit';
import {Request, Response} from 'express'; import {Request, Response} from 'express';
import {URL} from 'url'; import {URL} from 'url';
import _ from 'lodash';
// log api details outside of dev environment (when GRIST_HOSTED_VERSION is set) // log api details outside of dev environment (when GRIST_HOSTED_VERSION is set)
const shouldLogApiDetails = Boolean(process.env.GRIST_HOSTED_VERSION); const shouldLogApiDetails = Boolean(process.env.GRIST_HOSTED_VERSION);
@ -85,7 +86,7 @@ export function trustOrigin(req: Request, resp: Response): boolean {
const origin = req.get('origin'); const origin = req.get('origin');
if (!origin) { return true; } // Not a CORS request. if (!origin) { return true; } // Not a CORS request.
if (process.env.GRIST_HOST && req.hostname === process.env.GRIST_HOST) { return true; } if (process.env.GRIST_HOST && req.hostname === process.env.GRIST_HOST) { return true; }
if (!allowHost(req, new URL(origin))) { return false; } if (!allowHost(req, new URL(origin)) && !isEnvironmentAllowedHost(new URL(origin))) { return false; }
// For a request to a custom domain, the full hostname must match. // For a request to a custom domain, the full hostname must match.
resp.header("Access-Control-Allow-Origin", origin); resp.header("Access-Control-Allow-Origin", origin);
@ -107,10 +108,21 @@ export function allowHost(req: Request, allowedHost: string|URL) {
// For requests to a native subdomains, only the base domain needs to match. // For requests to a native subdomains, only the base domain needs to match.
const allowedDomain = parseSubdomain(allowedUrl.hostname); const allowedDomain = parseSubdomain(allowedUrl.hostname);
const actualDomain = parseSubdomain(actualUrl.hostname); const actualDomain = parseSubdomain(actualUrl.hostname);
return (actualDomain.base === allowedDomain.base); return (!_.isEmpty(actualDomain) ? actualDomain.base === allowedDomain.base : allowedUrl.hostname === actualUrl.hostname);
} }
} }
export function matchesBaseDomain(domain: string, baseDomain: string) {
return domain === baseDomain || domain.endsWith("." + baseDomain);
}
export function isEnvironmentAllowedHost(url: string|URL) {
const urlHost = (typeof url === 'string') ? url : url.host;
return (process.env.GRIST_ALLOWED_HOSTS || "").split(",").some(domain =>
domain && matchesBaseDomain(urlHost, domain)
);
}
export function isParameterOn(parameter: any): boolean { export function isParameterOn(parameter: any): boolean {
return gutil.isAffirmative(parameter); return gutil.isAffirmative(parameter);
} }

View File

@ -2773,6 +2773,23 @@ function testDocApi() {
}); });
}); });
describe("Allowed Origin", () => {
it('should allow only example.com', async () => {
async function checkOrigin(origin: string, status: number, error?: string) {
const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/`,
{...chimpy, headers: {...chimpy.headers, "Origin": origin}}
);
error && assert.deepEqual(resp.data, {error});
assert.equal(resp.status, status);
}
await checkOrigin("https://www.toto.com", 500, "Unrecognized origin");
await checkOrigin("https://badexample.com", 500, "Unrecognized origin");
await checkOrigin("https://bad.com/example.com/toto", 500, "Unrecognized origin");
await checkOrigin("https://example.com/path", 200);
await checkOrigin("https://good.example.com/toto", 200);
})
})
// PLEASE ADD MORE TESTS HERE // PLEASE ADD MORE TESTS HERE
} }
@ -2866,6 +2883,7 @@ class TestServer {
REDIS_URL: process.env.TEST_REDIS_URL, REDIS_URL: process.env.TEST_REDIS_URL,
APP_HOME_URL: _homeUrl, APP_HOME_URL: _homeUrl,
ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`, ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`,
GRIST_ALLOWED_HOSTS: `example.com,localhost:${webhooksTestPort}`,
...process.env ...process.env
}; };