Skip to content

Prevents documents being copied with external attachments #1433

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/server/lib/DocApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,15 +534,15 @@ export class DocWorkerApi {
}));

// Returns the status of any current / pending attachment transfers
this._app.get('/api/docs/:docId/attachments/transferStatus', isOwner, withDoc(async (activeDoc, req, res) => {
this._app.get('/api/docs/:docId/attachments/transferStatus', canView, withDoc(async (activeDoc, req, res) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should /attachments/store also be canView for consistency?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a little leakier, as it reveals (in the future) user label and maybe store type to unauthenticated users.

Happy with either, I'm not sure if that's a security concern or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said - all that info is in the downloaded Grist doc, thinking about it, so it's not private info. So yes, we could make it canView.

const locationSummary = await activeDoc.attachmentLocationSummary();
res.json({
status: activeDoc.attachmentTransferStatus(),
locationSummary,
});
}));

this._app.get('/api/docs/:docId/attachments/store', isOwner,
this._app.get('/api/docs/:docId/attachments/store', canView,
withDoc(async (activeDoc, req, res) => {
const storeId = await activeDoc.getAttachmentStore();
res.json({
Expand Down
28 changes: 25 additions & 3 deletions app/server/lib/uploads.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {ApiError} from 'app/common/ApiError';
import {InactivityTimer} from 'app/common/InactivityTimer';
import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads';
import {getUrlFromPrefix} from 'app/common/UserAPI';
import {DocAttachmentsLocation, getUrlFromPrefix} from 'app/common/UserAPI';
import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode,
RequestWithLogin} from 'app/server/lib/Authorizer';
import {expressWrap} from 'app/server/lib/expressWrap';
Expand Down Expand Up @@ -426,9 +426,31 @@ export async function fetchDoc(
// The backend needs to be well configured for this to work.
const { selfPrefix, docWorker } = await getDocWorkerInfoOrSelfPrefix(docId, docWorkerMap, server.getTag());
const docWorkerUrl = docWorker ? docWorker.internalUrl : getUrlFromPrefix(server.getHomeInternalUrl(), selfPrefix);
const apiBaseUrl = docWorkerUrl.replace(/\/*$/, '/');

// Documents with external attachments can't be copied right now. Check status and alert the user.
// Copying as a template is fine, as no attachments will be copied.
if (!template) {
const transferStatusResponse = await fetch(
new URL(`/api/docs/${docId}/attachments/transferStatus`, apiBaseUrl).href,
{
headers: {
...headers,
'Content-Type': 'application/json',
}
}
);
if (!transferStatusResponse.ok) {
throw new ApiError(await transferStatusResponse.text(), transferStatusResponse.status);
}
const attachmentsLocation: DocAttachmentsLocation = (await transferStatusResponse.json()).locationSummary;
if (attachmentsLocation !== 'internal' && attachmentsLocation !== 'none') {
throw new ApiError("Cannot copy a document with external attachments", 400);
}
}

// Download the document, in full or as a template.
const url = new URL(`api/docs/${docId}/download?template=${Number(template)}`,
docWorkerUrl.replace(/\/*$/, '/'));
const url = new URL(`api/docs/${docId}/download?template=${Number(template)}`, apiBaseUrl);
return _fetchURL(url.href, accessId, {headers});
}

Expand Down
65 changes: 44 additions & 21 deletions test/server/lib/DocApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2433,23 +2433,32 @@ function testDocApi(settings: {

});

async function addAttachmentsToDoc(docId: string, attachments: {name: string, contents: string}[],
user: AxiosRequestConfig = chimpy) {
const formData = new FormData();
for (const attachment of attachments) {
formData.append('upload', attachment.contents, attachment.name);
}
const resp = await axios.post(`${serverUrl}/api/docs/${docId}/attachments`, formData,
defaultsDeep({headers: formData.getHeaders()}, user));
assert.equal(resp.status, 200);
assert.equal(resp.data.length, attachments.length);
return resp;
}

describe('attachments', function () {
it("POST /docs/{did}/attachments adds attachments", async function () {
let formData = new FormData();
formData.append('upload', 'foobar', "hello.doc");
formData.append('upload', '123456', "world.jpg");
let resp = await axios.post(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments`, formData,
defaultsDeep({headers: formData.getHeaders()}, chimpy));
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, [1, 2]);
const uploadResp = await addAttachmentsToDoc(docIds.TestDoc, [
{ name: 'hello.doc', contents: 'foobar' },
{ name: 'world.jpg', contents: '123456' },
], chimpy);
assert.deepEqual(uploadResp.data, [1, 2]);

// Another upload gets the next number.
formData = new FormData();
formData.append('upload', 'abcdef', "hello.png");
resp = await axios.post(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments`, formData,
defaultsDeep({headers: formData.getHeaders()}, chimpy));
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, [3]);
const upload2Resp = await addAttachmentsToDoc(docIds.TestDoc, [
{ name: 'hello.png', contents: 'abcdef' },
], chimpy);
assert.deepEqual(upload2Resp.data, [3]);
});

it("GET /docs/{did}/attachments lists attachment metadata", async function () {
Expand Down Expand Up @@ -2753,14 +2762,11 @@ function testDocApi(settings: {
docId = await userApi.newDoc({name: 'TestDocExternalAttachments'}, wid);
docUrl = `${serverUrl}/api/docs/${docId}`;

const formData = new FormData();
formData.append('upload', 'foobar', "hello.doc");
formData.append('upload', '123456', "world.jpg");
formData.append('upload', 'foobar', "hello2.doc");
const resp = await axios.post(`${docUrl}/attachments`, formData,
defaultsDeep({headers: formData.getHeaders()}, chimpy));
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, [1, 2, 3]);
await addAttachmentsToDoc(docId, [
{ name: 'hello.doc', contents: 'foobar' },
{ name: 'world.jpg', contents: '123456' },
{ name: 'hello2.doc', contents: 'foobar' }
], chimpy);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You removed the last assert here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it into the addAttachmentsToDoc function - although it's now a length check rather than an exact ID check.

I'll re-add the exact ID check to be safe.

});

after(async () => {
Expand Down Expand Up @@ -2804,6 +2810,23 @@ function testDocApi(settings: {
locationSummary: "internal",
});
});

it("POST /docs/{did}/copy fails when the document has external attachments", async function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about forking?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forking works correctly under the external attachments model - forks use their parent's document pool, so that doesn't need blocking.

I did test with forks and the copy block, and "Save to copy" is also correctly blocked in a document fork.

const worker1 = await userApi.getWorkerAPI(docId);
await assert.isRejected(worker1.copyDoc(docId, undefined, 'copy'), /status 400/);
});

it("POST /docs/{did} with sourceDocId fails to copy a document with external attachments", async function () {
const chimpyWs = await userApi.newWorkspace({name: "Chimpy's Workspace"}, ORG_NAME);
const resp = await axios.post(`${serverUrl}/api/docs`, {
sourceDocumentId: docId,
documentName: 'copy of TestDocExternalAttachments',
asTemplate: false,
workspaceId: chimpyWs
}, chimpy);
assert.equal(resp.status, 400);
assert.match(resp.data.error, /external attachments/);
});
});
});

Expand Down