mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
8dbcbba6b5
commit
bd6a54e901
@ -119,6 +119,7 @@ export async function uploadFiles(
|
|||||||
return new Promise<UploadResult>((resolve, reject) => {
|
return new Promise<UploadResult>((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('post', docUrl(options.docWorkerUrl, UPLOAD_URL_PATH), true);
|
xhr.open('post', docUrl(options.docWorkerUrl, UPLOAD_URL_PATH), true);
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
xhr.upload.addEventListener('progress', (e) => {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
@ -170,3 +171,33 @@ export async function fetchURL(
|
|||||||
const res = await uploadFiles([fileObj], {docWorkerUrl: docComm.docWorkerUrl}, onProgress);
|
const res = await uploadFiles([fileObj], {docWorkerUrl: docComm.docWorkerUrl}, onProgress);
|
||||||
return res!;
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -139,7 +139,8 @@ function _logError(error: Error|string) {
|
|||||||
}),
|
}),
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
}
|
}
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
// There ... isn't much we can do about this.
|
// There ... isn't much we can do about this.
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs";
|
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 { AppModel, reportError } from "app/client/models/AppModel";
|
||||||
import { urlState } from "app/client/models/gristUrlState";
|
import { urlState } from "app/client/models/gristUrlState";
|
||||||
import { AccountWidget } from "app/client/ui/AccountWidget";
|
import { AccountWidget } from "app/client/ui/AccountWidget";
|
||||||
@ -59,11 +60,16 @@ export class WelcomePage extends Disposable {
|
|||||||
return form = dom(
|
return form = dom(
|
||||||
'form',
|
'form',
|
||||||
{ method: "post" },
|
{ 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.'),
|
cssLabel('Your full name, as you\'d like it displayed to your collaborators.'),
|
||||||
inputEl = cssInput(
|
inputEl = cssInput(
|
||||||
value, { onInput: true, },
|
value, { onInput: true, },
|
||||||
{ name: "username" },
|
{ 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),
|
dom.maybe((use) => use(value) && !use(isNameValid), buildNameWarningsDom),
|
||||||
cssButtonGroup(
|
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() {
|
private async _fetchOrgs() {
|
||||||
this._orgs = await this._appModel.api.getOrgs(true);
|
this._orgs = await this._appModel.api.getOrgs(true);
|
||||||
|
@ -51,6 +51,7 @@ export class BaseAPI {
|
|||||||
this._logger = options.logger || console;
|
this._logger = options.logger || console;
|
||||||
this._headers = {
|
this._headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
...options.headers
|
...options.headers
|
||||||
};
|
};
|
||||||
this._extraParameters = options.extraParameters;
|
this._extraParameters = options.extraParameters;
|
||||||
@ -61,6 +62,16 @@ export class BaseAPI {
|
|||||||
return this.request(url, init);
|
return this.request(url, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public defaultHeaders() {
|
||||||
|
return this._headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public defaultHeadersWithoutContentType() {
|
||||||
|
const headers = {...this.defaultHeaders()};
|
||||||
|
delete headers['Content-Type'];
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
// Similar to request, but uses the axios library, and supports progress indicator.
|
// Similar to request, but uses the axios library, and supports progress indicator.
|
||||||
@BaseAPI.countRequest
|
@BaseAPI.countRequest
|
||||||
protected async requestAxios(url: string, config: AxiosRequestConfig): Promise<AxiosResponse> {
|
protected async requestAxios(url: string, config: AxiosRequestConfig): Promise<AxiosResponse> {
|
||||||
|
@ -540,19 +540,21 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async fetchApiKey(): Promise<string> {
|
public async fetchApiKey(): Promise<string> {
|
||||||
const resp = await this.fetch(`${this._url}/api/profile/apiKey`, {
|
const resp = await this.request(`${this._url}/api/profile/apiKey`);
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
return await resp.text();
|
return await resp.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createApiKey(): Promise<string> {
|
public async createApiKey(): Promise<string> {
|
||||||
const res = await this.fetch(`${this._url}/api/profile/apiKey`, {credentials: 'include', method: 'POST'});
|
const res = await this.request(`${this._url}/api/profile/apiKey`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
return await res.text();
|
return await res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteApiKey(): Promise<void> {
|
public async deleteApiKey(): Promise<void> {
|
||||||
await this.fetch(`${this._url}/api/profile/apiKey`, {credentials: 'include', method: 'DELETE'});
|
await this.request(`${this._url}/api/profile/apiKey`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method is not strictly needed anymore, but is widely used by
|
// This method is not strictly needed anymore, but is widely used by
|
||||||
@ -578,10 +580,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
formData.append('upload', material as any, options.filename);
|
formData.append('upload', material as any, options.filename);
|
||||||
if (options.timezone) { formData.append('timezone', options.timezone); }
|
if (options.timezone) { formData.append('timezone', options.timezone); }
|
||||||
const resp = await this.requestAxios(`${this._url}/api/docs`, {
|
const resp = await this.requestAxios(`${this._url}/api/docs`, {
|
||||||
headers: this._options.headers,
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: formData,
|
data: formData,
|
||||||
onUploadProgress: options.onUploadProgress,
|
onUploadProgress: options.onUploadProgress,
|
||||||
|
// On browser, it is important not to set Content-Type so that the browser takes care
|
||||||
|
// of setting HTTP headers appropriately. Outside browser, requestAxios has logic
|
||||||
|
// for setting the HTTP headers.
|
||||||
|
headers: {...this.defaultHeadersWithoutContentType()},
|
||||||
});
|
});
|
||||||
return resp.data;
|
return resp.data;
|
||||||
}
|
}
|
||||||
@ -606,7 +611,7 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DocWorkerAPIImpl extends BaseAPI implements DocWorkerAPI {
|
export class DocWorkerAPIImpl extends BaseAPI implements DocWorkerAPI {
|
||||||
constructor(readonly url: string, private _options: IOptions = {}) {
|
constructor(readonly url: string, _options: IOptions = {}) {
|
||||||
super(_options);
|
super(_options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -622,7 +627,10 @@ export class DocWorkerAPIImpl extends BaseAPI implements DocWorkerAPI {
|
|||||||
const formData = this.newFormData();
|
const formData = this.newFormData();
|
||||||
formData.append('upload', material as any, filename);
|
formData.append('upload', material as any, filename);
|
||||||
const json = await this.requestJson(`${this.url}/uploads`, {
|
const json = await this.requestJson(`${this.url}/uploads`, {
|
||||||
headers: this._options.headers,
|
// On browser, it is important not to set Content-Type so that the browser takes care
|
||||||
|
// of setting HTTP headers appropriately. Outside of browser, node-fetch also appears
|
||||||
|
// to take care of this - https://github.github.io/fetch/#request-body
|
||||||
|
headers: {...this.defaultHeadersWithoutContentType()},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
@ -632,7 +640,6 @@ export class DocWorkerAPIImpl extends BaseAPI implements DocWorkerAPI {
|
|||||||
public async downloadDoc(docId: string, template: boolean = false): Promise<Response> {
|
public async downloadDoc(docId: string, template: boolean = false): Promise<Response> {
|
||||||
const extra = template ? '&template=1' : '';
|
const extra = template ? '&template=1' : '';
|
||||||
const result = await this.request(`${this.url}/download?doc=${docId}${extra}`, {
|
const result = await this.request(`${this.url}/download?doc=${docId}${extra}`, {
|
||||||
headers: this._options.headers,
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
if (!result.ok) { throw new Error(await result.text()); }
|
if (!result.ok) { throw new Error(await result.text()); }
|
||||||
@ -648,7 +655,6 @@ export class DocWorkerAPIImpl extends BaseAPI implements DocWorkerAPI {
|
|||||||
url.searchParams.append('name', name);
|
url.searchParams.append('name', name);
|
||||||
}
|
}
|
||||||
const json = await this.requestJson(url.href, {
|
const json = await this.requestJson(url.href, {
|
||||||
headers: this._options.headers,
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
return json.uploadId;
|
return json.uploadId;
|
||||||
|
@ -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
|
// A bit of extra info we'll add to the "Auth" log message when this request passes the check
|
||||||
// for custom-host-specific sessionID.
|
// for custom-host-specific sessionID.
|
||||||
let customHostSession = '';
|
let customHostSession = '';
|
||||||
@ -461,10 +471,12 @@ export function getTransitiveHeaders(req: Request): {[key: string]: string} {
|
|||||||
const Cookie = req.get('Cookie');
|
const Cookie = req.get('Cookie');
|
||||||
const PermitHeader = req.get('Permit');
|
const PermitHeader = req.get('Permit');
|
||||||
const Organization = (req as RequestWithOrg).org;
|
const Organization = (req as RequestWithOrg).org;
|
||||||
|
const XRequestedWith = req.get('X-Requested-With');
|
||||||
return {
|
return {
|
||||||
...(Authorization ? { Authorization } : undefined),
|
...(Authorization ? { Authorization } : undefined),
|
||||||
...(Cookie ? { Cookie } : undefined),
|
...(Cookie ? { Cookie } : undefined),
|
||||||
...(Organization ? { Organization } : undefined),
|
...(Organization ? { Organization } : undefined),
|
||||||
...(PermitHeader ? { Permit: PermitHeader } : undefined),
|
...(PermitHeader ? { Permit: PermitHeader } : undefined),
|
||||||
|
...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -942,7 +942,6 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
public addWelcomePaths() {
|
public addWelcomePaths() {
|
||||||
const middleware = [
|
const middleware = [
|
||||||
bodyParser.urlencoded({ extended: true }),
|
|
||||||
this._redirectToHostMiddleware,
|
this._redirectToHostMiddleware,
|
||||||
this._userIdMiddleware,
|
this._userIdMiddleware,
|
||||||
this._redirectToLoginWithoutExceptionsMiddleware,
|
this._redirectToLoginWithoutExceptionsMiddleware,
|
||||||
@ -967,7 +966,7 @@ export class FlexServer implements GristServer {
|
|||||||
const pathname = orgs && orgs.length > 1 ? '/welcome/teams' : '/';
|
const pathname = orgs && orgs.length > 1 ? '/welcome/teams' : '/';
|
||||||
const mergedOrgDomain = this.dbManager.mergedOrgDomain();
|
const mergedOrgDomain = this.dbManager.mergedOrgDomain();
|
||||||
const redirectUrl = this._getOrgRedirectUrl(mreq, mergedOrgDomain, pathname);
|
const redirectUrl = this._getOrgRedirectUrl(mreq, mergedOrgDomain, pathname);
|
||||||
resp.redirect(redirectUrl);
|
resp.json({redirectUrl});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.app.get('/welcome/teams', ...middleware, expressWrap(async (req, resp, next) => {
|
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
|
// 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.
|
// 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.
|
// 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) => {
|
this.app.get('/gen_csv', ...docAccessMiddleware, expressWrap(async (req, res) => {
|
||||||
return this._docWorker.getCSV(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)) {
|
if (trustOrigin(req, res)) {
|
||||||
res.header("Access-Control-Allow-Credentials", "true");
|
res.header("Access-Control-Allow-Credentials", "true");
|
||||||
res.header("Access-Control-Allow-Methods", "GET, PATCH, POST, DELETE, OPTIONS");
|
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) {
|
if ('OPTIONS' === req.method) {
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
|
@ -8,7 +8,7 @@ import {RequestWithGrist} from 'app/server/lib/FlexServer';
|
|||||||
import {GristServer} from 'app/server/lib/GristServer';
|
import {GristServer} from 'app/server/lib/GristServer';
|
||||||
import {guessExt} from 'app/server/lib/guessExt';
|
import {guessExt} from 'app/server/lib/guessExt';
|
||||||
import * as log from 'app/server/lib/log';
|
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 {isPathWithin} from 'app/server/lib/serverUtils';
|
||||||
import * as shutdown from 'app/server/lib/shutdown';
|
import * as shutdown from 'app/server/lib/shutdown';
|
||||||
import {fromCallback} from 'bluebird';
|
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.
|
// 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.
|
// 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) => {
|
expressApp.options([`/${UPLOAD_URL_PATH}`, '/copy'], ...handlers, async (req, res) => {
|
||||||
if (!trustOrigin(req, res)) { return res.status(500).send(); }
|
// Origin is checked by middleware - if we get this far, we are ok.
|
||||||
res.header("Access-Control-Allow-Credentials", "true");
|
|
||||||
res.status(200).send();
|
res.status(200).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
expressApp.post(`/${UPLOAD_URL_PATH}`, ...handlers, expressWrap(async (req: Request, res: Response) => {
|
expressApp.post(`/${UPLOAD_URL_PATH}`, ...handlers, expressWrap(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!trustOrigin(req, res)) {
|
|
||||||
throw new Error('Unrecognized origin');
|
|
||||||
}
|
|
||||||
res.header("Access-Control-Allow-Credentials", "true");
|
|
||||||
const uploadResult: UploadResult = await handleUpload(req, res);
|
const uploadResult: UploadResult = await handleUpload(req, res);
|
||||||
res.status(200).send(JSON.stringify(uploadResult));
|
res.status(200).send(JSON.stringify(uploadResult));
|
||||||
} catch (err) {
|
} 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.
|
// Like upload, but copy data from a document already known to us.
|
||||||
expressApp.post(`/copy`, ...handlers, expressWrap(async (req: Request, res: Response) => {
|
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 docId = optStringParam(req.query.doc);
|
||||||
const name = optStringParam(req.query.name);
|
const name = optStringParam(req.query.name);
|
||||||
if (!docId) { throw new Error('doc must be specified'); }
|
if (!docId) { throw new Error('doc must be specified'); }
|
||||||
|
Loading…
Reference in New Issue
Block a user