(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

@@ -119,6 +119,7 @@ export async function uploadFiles(
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('post', docUrl(options.docWorkerUrl, UPLOAD_URL_PATH), true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
@@ -170,3 +171,33 @@ export async function fetchURL(
const res = await uploadFiles([fileObj], {docWorkerUrl: docComm.docWorkerUrl}, onProgress);
return res!;
}
// Submit a form using XHR. Send inputs as JSON, and interpret any reply as JSON.
export async function submitForm(form: HTMLFormElement): Promise<any> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const data: {[key: string]: string} = {};
for (const element of [...form.getElementsByTagName('input')]) {
data[element.name] = element.value;
}
xhr.open('post', form.action, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.withCredentials = true;
xhr.send(JSON.stringify(data));
xhr.addEventListener('error', (e: ProgressEvent) => {
console.warn("Form error", e); // tslint:disable-line:no-console
reject(new Error('Form error, please try again'));
});
xhr.addEventListener('load', () => {
if (xhr.status !== 200) {
// tslint:disable-next-line:no-console
console.warn("Form failed", xhr.status, xhr.responseText);
const err = safeJsonParse(xhr.responseText, null);
reject(new UserError('Form failed: ' + (err && err.error || xhr.status)));
} else {
resolve(safeJsonParse(xhr.responseText, null));
}
});
});
}

View File

@@ -139,7 +139,8 @@ function _logError(error: Error|string) {
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
}
}).catch(e => {
// There ... isn't much we can do about this.

View File

@@ -1,5 +1,6 @@
import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs";
import { submitForm } from "app/client/lib/uploads";
import { AppModel, reportError } from "app/client/models/AppModel";
import { urlState } from "app/client/models/gristUrlState";
import { AccountWidget } from "app/client/ui/AccountWidget";
@@ -59,11 +60,16 @@ export class WelcomePage extends Disposable {
return form = dom(
'form',
{ method: "post" },
dom.on('submit', (e) => {
e.preventDefault();
this._submitForm(form).catch(reportError);
return false;
}),
cssLabel('Your full name, as you\'d like it displayed to your collaborators.'),
inputEl = cssInput(
value, { onInput: true, },
{ name: "username" },
dom.onKeyDown({Enter: () => isNameValid.get() && form.submit()}),
dom.onKeyDown({Enter: () => isNameValid.get() && this._submitForm(form).catch(reportError)}),
),
dom.maybe((use) => use(value) && !use(isNameValid), buildNameWarningsDom),
cssButtonGroup(
@@ -76,6 +82,15 @@ export class WelcomePage extends Disposable {
);
}
private async _submitForm(form: HTMLFormElement) {
const result = await submitForm(form);
const redirectUrl = result.redirectUrl;
if (!redirectUrl) {
throw new Error('form failed to redirect');
}
window.location.assign(redirectUrl);
return false;
}
private async _fetchOrgs() {
this._orgs = await this._appModel.api.getOrgs(true);