(core) mitigate csrf by requiring custom header for unsafe methods

Summary:
For methods other than `GET`, `HEAD`, and `OPTIONS`, allow cookie-based authentication only if a certain custom header is present.

Specifically, we check that `X-Requested-With` is set to `XMLHttpRequest`. This is somewhat arbitrary, but allows us to use https://expressjs.com/en/api.html#req.xhr.

A request send from a browser that sets a custom header will prompt a preflight check, giving us a chance to check if the origin is trusted.

This diff deals with getting the header in place. There will be more work to do after this:
 * Make sure that all important endpoints are checking origin.  Skimming code, /api endpoint check origin, and some but not all others.
 * Add tests spot-testing origin checks.
 * Check on cases that authenticate differently.
    - Check the websocket endpoint - it can be connected to from an arbitrary site; there is per-doc access control but probably better to lock it down more.
    - There may be old endpoints that authenticate based on knowledge of a client id rather than cookies.

Test Plan: added a test

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2631
This commit is contained in:
Paul Fitzpatrick
2020-10-08 09:28:39 -04:00
parent 8dbcbba6b5
commit bd6a54e901
8 changed files with 96 additions and 28 deletions

View File

@@ -135,6 +135,16 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer
}
}
// If we haven't already been authenticated, and this is not a GET/HEAD/OPTIONS, then
// require that the X-Requested-With header field be set to XMLHttpRequest.
// This is trivial for legitimate web clients to do, and an obstacle to
// nefarious ones.
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers
// https://markitzeroday.com/x-requested-with/cors/2017/06/29/csrf-mitigation-for-ajax-requests.html
if (!mreq.userId && !mreq.xhr && !['GET', 'HEAD', 'OPTIONS'].includes(mreq.method)) {
return res.status(401).send('Bad request (missing header)');
}
// A bit of extra info we'll add to the "Auth" log message when this request passes the check
// for custom-host-specific sessionID.
let customHostSession = '';
@@ -461,10 +471,12 @@ export function getTransitiveHeaders(req: Request): {[key: string]: string} {
const Cookie = req.get('Cookie');
const PermitHeader = req.get('Permit');
const Organization = (req as RequestWithOrg).org;
const XRequestedWith = req.get('X-Requested-With');
return {
...(Authorization ? { Authorization } : undefined),
...(Cookie ? { Cookie } : undefined),
...(Organization ? { Organization } : undefined),
...(PermitHeader ? { Permit: PermitHeader } : undefined),
...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
};
}

View File

@@ -942,7 +942,6 @@ export class FlexServer implements GristServer {
public addWelcomePaths() {
const middleware = [
bodyParser.urlencoded({ extended: true }),
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
@@ -967,7 +966,7 @@ export class FlexServer implements GristServer {
const pathname = orgs && orgs.length > 1 ? '/welcome/teams' : '/';
const mergedOrgDomain = this.dbManager.mergedOrgDomain();
const redirectUrl = this._getOrgRedirectUrl(mreq, mergedOrgDomain, pathname);
resp.redirect(redirectUrl);
resp.json({redirectUrl});
}));
this.app.get('/welcome/teams', ...middleware, expressWrap(async (req, resp, next) => {
@@ -1078,7 +1077,7 @@ export class FlexServer implements GristServer {
// Add the handling for the /upload route. Most uploads are meant for a DocWorker: they are put
// in temporary files, and the DocWorker needs to be on the same machine to have access to them.
// This doesn't check for doc access permissions because the request isn't tied to a document.
addUploadRoute(this, this.app, ...basicMiddleware);
addUploadRoute(this, this.app, this._trustOriginsMiddleware, ...basicMiddleware);
this.app.get('/gen_csv', ...docAccessMiddleware, expressWrap(async (req, res) => {
return this._docWorker.getCSV(req, res);
@@ -1408,7 +1407,9 @@ function trustOriginHandler(req: express.Request, res: express.Response, next: e
if (trustOrigin(req, res)) {
res.header("Access-Control-Allow-Credentials", "true");
res.header("Access-Control-Allow-Methods", "GET, PATCH, POST, DELETE, OPTIONS");
res.header("Access-Control-Allow-Headers", "Authorization, Content-Type");
res.header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With");
} else {
throw new Error('Unrecognized origin');
}
if ('OPTIONS' === req.method) {
res.sendStatus(200);

View File

@@ -8,7 +8,7 @@ import {RequestWithGrist} from 'app/server/lib/FlexServer';
import {GristServer} from 'app/server/lib/GristServer';
import {guessExt} from 'app/server/lib/guessExt';
import * as log from 'app/server/lib/log';
import {optStringParam, trustOrigin} from 'app/server/lib/requestUtils';
import {optStringParam} from 'app/server/lib/requestUtils';
import {isPathWithin} from 'app/server/lib/serverUtils';
import * as shutdown from 'app/server/lib/shutdown';
import {fromCallback} from 'bluebird';
@@ -42,18 +42,13 @@ export function addUploadRoute(server: GristServer, expressApp: Application, ...
// When doing a cross-origin post, the browser will check for access with options prior to posting.
// We need to reassure it that the request will be accepted before it will go ahead and post.
expressApp.options([`/${UPLOAD_URL_PATH}`, '/copy'], async (req: Request, res: Response) => {
if (!trustOrigin(req, res)) { return res.status(500).send(); }
res.header("Access-Control-Allow-Credentials", "true");
expressApp.options([`/${UPLOAD_URL_PATH}`, '/copy'], ...handlers, async (req, res) => {
// Origin is checked by middleware - if we get this far, we are ok.
res.status(200).send();
});
expressApp.post(`/${UPLOAD_URL_PATH}`, ...handlers, expressWrap(async (req: Request, res: Response) => {
try {
if (!trustOrigin(req, res)) {
throw new Error('Unrecognized origin');
}
res.header("Access-Control-Allow-Credentials", "true");
const uploadResult: UploadResult = await handleUpload(req, res);
res.status(200).send(JSON.stringify(uploadResult));
} catch (err) {
@@ -67,10 +62,6 @@ export function addUploadRoute(server: GristServer, expressApp: Application, ...
// Like upload, but copy data from a document already known to us.
expressApp.post(`/copy`, ...handlers, expressWrap(async (req: Request, res: Response) => {
if (!trustOrigin(req, res)) {
throw new Error('Unrecognized origin');
}
res.header("Access-Control-Allow-Credentials", "true");
const docId = optStringParam(req.query.doc);
const name = optStringParam(req.query.name);
if (!docId) { throw new Error('doc must be specified'); }