gristlabs_grist-core/app/server/lib/DocWorker.ts
Paul Fitzpatrick ea71312d0e (core) deal with write access for attachments
Summary:
Attachments are a special case for granular access control. A user is now allowed to read a given attachment if they have read access to a cell containing its id. So when a user writes to a cell in an attachment column, it is important that they can only write the ids of cells to which they have access. This diff allows a user to add an attachment id in a cell if:

  * The user already has access to that a attachment via some existing cell, or
  * The user recently updated the attachment, or
  * The attachment change is from an undo/redo of a previous action attributed to that user

Test Plan: Updated tests

Reviewers: georgegevoian, dsagal

Reviewed By: georgegevoian, dsagal

Differential Revision: https://phab.getgrist.com/D3681
2022-11-15 09:52:32 -05:00

215 lines
10 KiB
TypeScript

/**
* DocWorker collects the methods and endpoints that relate to a single Grist document.
* In hosted environment, this comprises the functionality of the DocWorker instance type.
*/
import {isAffirmative} from 'app/common/gutil';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
import {assertAccess, getOrSetDocAuth, RequestWithLogin} from 'app/server/lib/Authorizer';
import {Client} from 'app/server/lib/Client';
import {Comm} from 'app/server/lib/Comm';
import {DocSession, docSessionFromRequest} from 'app/server/lib/DocSession';
import {filterDocumentInPlace} from 'app/server/lib/filterUtils';
import {GristServer} from 'app/server/lib/GristServer';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import log from 'app/server/lib/log';
import {getDocId, integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils';
import {OpenMode, quoteIdent, SQLiteDB} from 'app/server/lib/SQLiteDB';
import contentDisposition from 'content-disposition';
import * as express from 'express';
import * as fse from 'fs-extra';
import * as mimeTypes from 'mime-types';
import * as path from 'path';
export interface AttachOptions {
comm: Comm; // Comm object for methods called via websocket
gristServer: GristServer;
}
export class DocWorker {
private _comm: Comm;
private _gristServer: GristServer;
constructor(private _dbManager: HomeDBManager, options: AttachOptions) {
this._comm = options.comm;
this._gristServer = options.gristServer;
}
public async getAttachment(req: express.Request, res: express.Response): Promise<void> {
try {
const docSession = this._getDocSession(stringParam(req.query.clientId, 'clientId'),
integerParam(req.query.docFD, 'docFD'));
const activeDoc = docSession.activeDoc;
const colId = stringParam(req.query.colId, 'colId');
const tableId = stringParam(req.query.tableId, 'tableId');
const rowId = integerParam(req.query.rowId, 'rowId');
const cell = {colId, tableId, rowId};
const maybeNew = isAffirmative(req.query.maybeNew);
const attId = integerParam(req.query.attId, 'attId');
const attRecord = activeDoc.getAttachmentMetadata(attId);
const ext = path.extname(attRecord.fileIdent);
const type = mimeTypes.lookup(ext);
let inline = Boolean(req.query.inline);
// Serving up user-uploaded HTML files inline is an open door to XSS attacks.
if (type === "text/html") { inline = false; }
// Construct a content-disposition header of the form 'inline|attachment; filename="NAME"'
const contentDispType = inline ? "inline" : "attachment";
const contentDispHeader = contentDisposition(stringParam(req.query.name, 'name'), {type: contentDispType});
const data = await activeDoc.getAttachmentData(docSession, attRecord, {cell, maybeNew});
res.status(200)
.type(ext)
.set('Content-Disposition', contentDispHeader)
.set('Cache-Control', 'private, max-age=3600')
.send(data);
} catch (err) {
res.status(404).send({error: err.toString()});
}
}
public async downloadDoc(req: express.Request, res: express.Response,
storageManager: IDocStorageManager): Promise<void> {
const mreq = req as RequestWithLogin;
const docId = getDocId(mreq);
// Query DB for doc metadata to get the doc title.
const doc = await this._dbManager.getDoc(req);
const docTitle = doc.name;
// Get a copy of document for downloading.
const tmpPath = await storageManager.getCopy(docId);
if (req.query.template === '1') {
// If template flag is on, remove data and history from the download.
await removeData(tmpPath);
}
await filterDocumentInPlace(docSessionFromRequest(mreq), tmpPath);
// NOTE: We may want to reconsider the mimeType used for Grist files.
return res.type('application/x-sqlite3')
.download(tmpPath, (optStringParam(req.query.title) || docTitle || 'document') + ".grist", async (err: any) => {
if (err) {
if (err.message && /Request aborted/.test(err.message)) {
log.warn(`Download request aborted for doc ${docId}`, err);
} else {
log.error(`Download failure for doc ${docId}`, err);
}
}
await fse.unlink(tmpPath);
});
}
// Register main methods related to documents.
public registerCommCore(): void {
const comm = this._comm;
comm.registerMethods({
closeDoc: activeDocMethod.bind(null, null, 'closeDoc'),
fetchTable: activeDocMethod.bind(null, 'viewers', 'fetchTable'),
fetchTableSchema: activeDocMethod.bind(null, 'viewers', 'fetchTableSchema'),
useQuerySet: activeDocMethod.bind(null, 'viewers', 'useQuerySet'),
disposeQuerySet: activeDocMethod.bind(null, 'viewers', 'disposeQuerySet'),
applyUserActions: activeDocMethod.bind(null, 'editors', 'applyUserActions'),
applyUserActionsById: activeDocMethod.bind(null, 'editors', 'applyUserActionsById'),
findColFromValues: activeDocMethod.bind(null, 'viewers', 'findColFromValues'),
getFormulaError: activeDocMethod.bind(null, 'viewers', 'getFormulaError'),
importFiles: activeDocMethod.bind(null, 'editors', 'importFiles'),
finishImportFiles: activeDocMethod.bind(null, 'editors', 'finishImportFiles'),
cancelImportFiles: activeDocMethod.bind(null, 'editors', 'cancelImportFiles'),
generateImportDiff: activeDocMethod.bind(null, 'editors', 'generateImportDiff'),
addAttachments: activeDocMethod.bind(null, 'editors', 'addAttachments'),
removeInstanceFromDoc: activeDocMethod.bind(null, 'editors', 'removeInstanceFromDoc'),
startBundleUserActions: activeDocMethod.bind(null, 'editors', 'startBundleUserActions'),
stopBundleUserActions: activeDocMethod.bind(null, 'editors', 'stopBundleUserActions'),
autocomplete: activeDocMethod.bind(null, 'viewers', 'autocomplete'),
fetchURL: activeDocMethod.bind(null, 'viewers', 'fetchURL'),
getActionSummaries: activeDocMethod.bind(null, 'viewers', 'getActionSummaries'),
reloadDoc: activeDocMethod.bind(null, 'editors', 'reloadDoc'),
fork: activeDocMethod.bind(null, 'viewers', 'fork'),
checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'),
getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'),
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
getAccessToken: activeDocMethod.bind(null, 'viewers', 'getAccessToken'),
});
}
// Register methods related to plugins.
public registerCommPlugin(): void {
this._comm.registerMethods({
forwardPluginRpc: activeDocMethod.bind(null, 'editors', 'forwardPluginRpc'),
// TODO: consider not providing reloadPlugins on hosted grist, since it affects the
// plugin manager shared across docs on a given doc worker, and seems useful only in
// standalone case.
reloadPlugins: activeDocMethod.bind(null, 'editors', 'reloadPlugins'),
});
}
// Checks that document is accessible, and adds docAuth information to request.
// Otherwise issues a 403 access denied.
// (This is used for endpoints like /download, /gen-csv, /attachment.)
public async assertDocAccess(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const mreq = req as RequestWithLogin;
let urlId: string|undefined;
try {
if (optStringParam(req.query.clientId)) {
const activeDoc = this._getDocSession(stringParam(req.query.clientId, 'clientId'),
integerParam(req.query.docFD, 'docFD')).activeDoc;
// TODO: The docId should be stored in the ActiveDoc class. Currently docName is
// used instead, which will coincide with the docId for hosted grist but not for
// standalone grist.
urlId = activeDoc.docName;
} else {
// Otherwise, if being used without a client, expect the doc query parameter to
// be the docId.
urlId = stringParam(req.query.doc, 'doc');
}
if (!urlId) { return res.status(403).send({error: 'missing document id'}); }
const docAuth = await getOrSetDocAuth(mreq, this._dbManager, this._gristServer, urlId);
assertAccess('viewers', docAuth);
next();
} catch (err) {
log.info(`DocWorker can't access document ${urlId} with userId ${mreq.userId}: ${err}`);
res.status(err.status || 404).send({error: err.toString()});
}
}
private _getDocSession(clientId: string, docFD: number): DocSession {
const client = this._comm.getClient(clientId);
return client.getDocSession(docFD);
}
}
/**
* Translates calls from the browser client into calls of the form
* `activeDoc.method(docSession, ...args)`.
*/
async function activeDocMethod(role: 'viewers'|'editors'|null, methodName: string, client: Client,
docFD: number, ...args: any[]): Promise<any> {
const docSession = client.getDocSession(docFD);
const activeDoc = docSession.activeDoc;
if (role) { await docSession.authorizer.assertAccess(role); }
// Include a basic log record for each ActiveDoc method call.
log.rawDebug('activeDocMethod', activeDoc.getLogMeta(docSession, methodName));
return (activeDoc as any)[methodName](docSession, ...args);
}
/**
* Remove rows from all user tables, and wipe as much history as we can.
*/
async function removeData(filename: string) {
const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);
const tableIds = (await db.all("SELECT name FROM sqlite_master WHERE type='table'"))
.map(row => row.name as string)
.filter(name => !name.startsWith('_grist'));
for (const tableId of tableIds) {
await db.run(`DELETE FROM ${quoteIdent(tableId)}`);
}
const history = new ActionHistoryImpl(db);
await history.deleteActions(1);
await db.vacuum();
await db.close();
}