Skip to content

Commit 5eb3a92

Browse files
committed
Server: Prevent large data blobs from crashing the application
Ref: brianc/node-postgres#2653
1 parent 73b702b commit 5eb3a92

File tree

6 files changed

+79
-15
lines changed

6 files changed

+79
-15
lines changed

packages/server/src/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
133133
cookieSecure: env.COOKIES_SECURE,
134134
storageDriver: parseStorageDriverConnectionString(env.STORAGE_DRIVER),
135135
storageDriverFallback: parseStorageDriverConnectionString(env.STORAGE_DRIVER_FALLBACK),
136+
itemSizeHardLimit: 250000000, // Beyond this the Postgres driver will crash the app
136137
...overrides,
137138
};
138139
}

packages/server/src/models/BaseModel.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export default abstract class BaseModel<T> {
9090
return this.config_.appName;
9191
}
9292

93+
protected get itemSizeHardLimit(): number {
94+
return this.config_.itemSizeHardLimit;
95+
}
96+
9397
public get db(): DbConnection {
9498
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
9599
return this.db_;
@@ -113,7 +117,7 @@ export default abstract class BaseModel<T> {
113117
throw new Error('Must be overriden');
114118
}
115119

116-
protected selectFields(options: LoadOptions, defaultFields: string[] = null, mainTable: string = ''): string[] {
120+
protected selectFields(options: LoadOptions, defaultFields: string[] = null, mainTable: string = '', requiredFields: string[] = []): string[] {
117121
let output: string[] = [];
118122
if (options && options.fields) {
119123
output = options.fields;
@@ -123,6 +127,12 @@ export default abstract class BaseModel<T> {
123127
output = this.defaultFields;
124128
}
125129

130+
if (!output.includes('*')) {
131+
for (const f of requiredFields) {
132+
if (!output.includes(f)) output.push(f);
133+
}
134+
}
135+
126136
if (mainTable) {
127137
output = output.map(f => {
128138
if (f.includes(`${mainTable}.`)) return f;

packages/server/src/models/ItemModel.test.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createItem, createItemTree, createResource, createNote, createFolder, createItemTree3, db, tempDir } from '../utils/testing/testUtils';
1+
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createItem, createItemTree, createResource, createNote, createFolder, createItemTree3, db, tempDir, expectNotThrow, expectHttpError } from '../utils/testing/testUtils';
22
import { shareFolderWithUser } from '../utils/testing/shareApiUtils';
33
import { resourceBlobPath } from '../utils/joplinUtils';
44
import newModelFactory from './factory';
55
import { StorageDriverType } from '../utils/types';
66
import config from '../config';
77
import { msleep } from '../utils/time';
88
import loadStorageDriver from './items/storage/loadStorageDriver';
9+
import { ErrorPayloadTooLarge } from '../utils/errors';
910

1011
describe('ItemModel', function() {
1112

@@ -270,6 +271,36 @@ describe('ItemModel', function() {
270271
expect((await models().user().load(user3.id)).total_item_size).toBe(expected3);
271272
});
272273

274+
test('should respect the hard item size limit', async function() {
275+
const { user: user1 } = await createUserAndSession(1);
276+
277+
let models = newModelFactory(db(), config());
278+
279+
let result = await models.item().saveFromRawContent(user1, {
280+
body: Buffer.from('1234'),
281+
name: 'test1.txt',
282+
});
283+
284+
const item = result['test1.txt'].item;
285+
286+
config().itemSizeHardLimit = 3;
287+
models = newModelFactory(db(), config());
288+
289+
result = await models.item().saveFromRawContent(user1, {
290+
body: Buffer.from('1234'),
291+
name: 'test2.txt',
292+
});
293+
294+
expect(result['test2.txt'].error.httpCode).toBe(ErrorPayloadTooLarge.httpCode);
295+
296+
await expectHttpError(async () => models.item().loadWithContent(item.id), ErrorPayloadTooLarge.httpCode);
297+
298+
config().itemSizeHardLimit = 1000;
299+
models = newModelFactory(db(), config());
300+
301+
await expectNotThrow(async () => models.item().loadWithContent(item.id));
302+
});
303+
273304
test('should allow importing content to item storage', async function() {
274305
const { user: user1 } = await createUserAndSession(1);
275306

packages/server/src/models/ItemModel.ts

+20-13
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ItemType, databaseSchema, Uuid, Item, ShareType, Share, ChangeType, Use
33
import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
44
import { isJoplinItemName, isJoplinResourceBlobPath, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../utils/joplinUtils';
55
import { ModelType } from '@joplin/lib/BaseModel';
6-
import { ApiError, ErrorCode, ErrorForbidden, ErrorUnprocessableEntity } from '../utils/errors';
6+
import { ApiError, ErrorCode, ErrorForbidden, ErrorPayloadTooLarge, ErrorUnprocessableEntity } from '../utils/errors';
77
import { Knex } from 'knex';
88
import { ChangePreviousItem } from './ChangeModel';
99
import { unique } from '../utils/array';
@@ -14,6 +14,7 @@ import { NewModelFactoryHandler } from './factory';
1414
import loadStorageDriver from './items/storage/loadStorageDriver';
1515
import { msleep } from '../utils/time';
1616
import Logger from '@joplin/lib/Logger';
17+
import prettyBytes = require('pretty-bytes');
1718

1819
const mimeUtils = require('@joplin/lib/mime-utils.js').mime;
1920

@@ -182,7 +183,11 @@ export default class ItemModel extends BaseModel<Item> {
182183
}
183184
}
184185

185-
private async storageDriverRead(itemId: Uuid, context: Context) {
186+
private async storageDriverRead(itemId: Uuid, itemSize: number, context: Context) {
187+
if (itemSize > this.itemSizeHardLimit) {
188+
throw new ErrorPayloadTooLarge(`Downloading items larger than ${prettyBytes(this.itemSizeHardLimit)} is currently disabled`);
189+
}
190+
186191
const storageDriver = await this.storageDriver();
187192
const storageDriverFallback = await this.storageDriverFallback();
188193

@@ -203,13 +208,13 @@ export default class ItemModel extends BaseModel<Item> {
203208
const rows: Item[] = await this
204209
.db('user_items')
205210
.leftJoin('items', 'items.id', 'user_items.item_id')
206-
.distinct(this.selectFields(options, null, 'items'))
211+
.distinct(this.selectFields(options, null, 'items', ['items.content_size']))
207212
.whereIn('user_items.user_id', userIds)
208213
.whereIn('jop_id', jopIds);
209214

210215
if (options.withContent) {
211216
for (const row of rows) {
212-
row.content = await this.storageDriverRead(row.id, { models: this.models() });
217+
row.content = await this.storageDriverRead(row.id, row.content_size, { models: this.models() });
213218
}
214219
}
215220

@@ -229,13 +234,13 @@ export default class ItemModel extends BaseModel<Item> {
229234
const rows: Item[] = await this
230235
.db('user_items')
231236
.leftJoin('items', 'items.id', 'user_items.item_id')
232-
.distinct(this.selectFields(options, null, 'items'))
237+
.distinct(this.selectFields(options, null, 'items', ['items.content_size']))
233238
.whereIn('user_items.user_id', userIds)
234239
.whereIn('name', names);
235240

236241
if (options.withContent) {
237242
for (const row of rows) {
238-
row.content = await this.storageDriverRead(row.id, { models: this.models() });
243+
row.content = await this.storageDriverRead(row.id, row.content_size, { models: this.models() });
239244
}
240245
}
241246

@@ -248,15 +253,17 @@ export default class ItemModel extends BaseModel<Item> {
248253
}
249254

250255
public async loadWithContent(id: Uuid, options: ItemLoadOptions = {}): Promise<Item> {
251-
const content = await this.storageDriverRead(id, { models: this.models() });
256+
const item: Item = await this
257+
.db('user_items')
258+
.leftJoin('items', 'items.id', 'user_items.item_id')
259+
.select(this.selectFields(options, ['*'], 'items', ['items.content_size']))
260+
.where('items.id', '=', id)
261+
.first();
262+
263+
const content = await this.storageDriverRead(id, item.content_size, { models: this.models() });
252264

253265
return {
254-
...await this
255-
.db('user_items')
256-
.leftJoin('items', 'items.id', 'user_items.item_id')
257-
.select(this.selectFields(options, ['*'], 'items'))
258-
.where('items.id', '=', id)
259-
.first(),
266+
...item,
260267
content,
261268
};
262269
}

packages/server/src/models/UserModel.ts

+14
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import paymentFailedAccountDisabledTemplate from '../views/emails/paymentFailedA
2626
import changeEmailConfirmationTemplate from '../views/emails/changeEmailConfirmationTemplate';
2727
import changeEmailNotificationTemplate from '../views/emails/changeEmailNotificationTemplate';
2828
import { NotificationKey } from './NotificationModel';
29+
import prettyBytes = require('pretty-bytes');
2930

3031
const logger = Logger.create('UserModel');
3132

@@ -197,6 +198,17 @@ export default class UserModel extends BaseModel<User> {
197198

198199
const maxItemSize = getMaxItemSize(user);
199200
const maxSize = maxItemSize * (itemIsEncrypted(item) ? 2.2 : 1);
201+
202+
if (itemSize > 200000000) {
203+
logger.info(`Trying to upload large item: ${JSON.stringify({
204+
userId: user.id,
205+
itemName: item.name,
206+
itemSize,
207+
maxItemSize,
208+
maxSize,
209+
}, null, ' ')}`);
210+
}
211+
200212
if (maxSize && itemSize > maxSize) {
201213
throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than the allowed limit (%s)',
202214
isNote ? _('note') : _('attachment'),
@@ -205,6 +217,8 @@ export default class UserModel extends BaseModel<User> {
205217
));
206218
}
207219

220+
if (itemSize > this.itemSizeHardLimit) throw new ErrorPayloadTooLarge(`Uploading items larger than ${prettyBytes(this.itemSizeHardLimit)} is currently disabled`);
221+
208222
// We allow lock files to go through so that sync can happen, which in
209223
// turns allow user to fix oversized account by deleting items.
210224
const isWhiteListed = itemSize < 200 && item.name.startsWith('locks/');

packages/server/src/utils/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export interface Config {
159159
cookieSecure: boolean;
160160
storageDriver: StorageDriverConfig;
161161
storageDriverFallback: StorageDriverConfig;
162+
itemSizeHardLimit: number;
162163
}
163164

164165
export enum HttpMethod {

0 commit comments

Comments
 (0)