Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
67 changes: 67 additions & 0 deletions spec/Utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,73 @@ describe('Utils', () => {
});
});

describe('deepClone', () => {
it('should return primitives unchanged', () => {
expect(Utils.deepClone(null)).toBeNull();
expect(Utils.deepClone(undefined)).toBeUndefined();
expect(Utils.deepClone(42)).toBe(42);
expect(Utils.deepClone('string')).toBe('string');
expect(Utils.deepClone(true)).toBe(true);
expect(Utils.deepClone(10n)).toBe(10n);
});

it('should deep-clone plain objects and arrays without sharing references', () => {
const original = { a: 1, b: { c: [1, 2, { d: 'x' }] }, e: null, f: undefined };
const clone = Utils.deepClone(original);
expect(clone).toEqual(original);
expect(clone).not.toBe(original);
expect(clone.b).not.toBe(original.b);
expect(clone.b.c).not.toBe(original.b.c);
expect(clone.b.c[2]).not.toBe(original.b.c[2]);
expect(Object.prototype.hasOwnProperty.call(clone, 'f')).toBe(true);
clone.b.c[2].d = 'changed';
expect(original.b.c[2].d).toBe('x');
});

it('should clone Date and typed array values like structuredClone', () => {
const date = new Date('2020-01-01T00:00:00.000Z');
const bytes = new Uint8Array([1, 2, 3]);
const clone = Utils.deepClone({ date, bytes });
expect(clone.date).not.toBe(date);
expect(clone.date.getTime()).toBe(date.getTime());
expect(clone.bytes).not.toBe(bytes);
expect(Array.from(clone.bytes)).toEqual([1, 2, 3]);
});

it('should not be vulnerable to prototype poisoning via __proto__', () => {
const original = JSON.parse('{"__proto__":{"polluted":"yes"},"a":1}');
const clone = Utils.deepClone(original);
expect(({}).polluted).toBeUndefined();
expect(Object.getPrototypeOf(clone)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(clone, '__proto__')).toBe(true);
expect(clone.a).toBe(1);
});

it('should clone objects with a null prototype as plain objects', () => {
const original = Object.create(null);
original.a = { b: 1 };
const clone = Utils.deepClone(original);
expect(clone.a).toEqual({ b: 1 });
expect(clone.a).not.toBe(original.a);
});

it('should preserve aliased and cyclic references', () => {
const shared = { v: 1 };
const original = { x: shared, y: shared };
original.self = original;
const clone = Utils.deepClone(original);
expect(clone.x).toBe(clone.y);
expect(clone.x).not.toBe(shared);
expect(clone.self).toBe(clone);
});

it('should throw for non-cloneable values like structuredClone', () => {
expect(() => Utils.deepClone(() => {})).toThrow();
expect(() => Utils.deepClone({ fn: () => {} })).toThrow();
expect(() => Utils.deepClone(Symbol('x'))).toThrow();
});
});

describe('getFileExtension', () => {
const cases = [
['file.txt', 'txt'],
Expand Down
7 changes: 6 additions & 1 deletion src/Adapters/Cache/SchemaCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ export default {
return [...(SchemaCache.allClasses || [])];
},

// The stored array itself as identity key for derived caches; must not be mutated
raw() {
return SchemaCache.allClasses;
},

get(className) {
return this.all().find(cached => cached.className === className);
return (SchemaCache.allClasses || []).find(cached => cached.className === className);
},

put(allSchema) {
Expand Down
21 changes: 18 additions & 3 deletions src/Adapters/Storage/Mongo/MongoTransform.js
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,20 @@ const transformPointerString = (schema, field, pointerString) => {

// Converts from a mongo-format object to a REST-format object.
// Does not strip out anything based on a lack of authentication.
// Relation field names per schema object; recomputing this per row is wasteful
// for large result sets (parse-community/parse-server#8689)
const relationFieldNamesCache = new WeakMap();
function getRelationFieldNames(schema) {
let names = relationFieldNamesCache.get(schema);
if (!names) {
names = Object.keys(schema.fields).filter(
fieldName => schema.fields[fieldName].type === 'Relation'
);
relationFieldNamesCache.set(schema, names);
}
return names;
}

const mongoObjectToParseObject = (className, mongoObject, schema) => {
switch (typeof mongoObject) {
case 'string':
Expand Down Expand Up @@ -1274,9 +1288,10 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => {
}
}

const relationFieldNames = Object.keys(schema.fields).filter(
fieldName => schema.fields[fieldName].type === 'Relation'
);
const relationFieldNames = getRelationFieldNames(schema);
if (relationFieldNames.length === 0) {
return restObject;
}
const relationFields = {};
relationFieldNames.forEach(relationFieldName => {
relationFields[relationFieldName] = {
Expand Down
44 changes: 33 additions & 11 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,46 +43,68 @@ function removeTrailingSlash(str) {
*/
const asyncKeys = ['publicServerURL'];

// Per-app Config prototypes; AppCache.put stores a new object on config
// changes, which invalidates the entry
const configTemplates = new WeakMap();

export class Config {
static get(applicationId: string, mount: string) {
const cacheInfo = AppCache.get(applicationId);
if (!cacheInfo) {
return;
}
const config = new Config();
config.applicationId = applicationId;
Object.keys(cacheInfo).forEach(key => {
if (key == 'databaseController') {
config.database = new DatabaseController(cacheInfo.databaseController.adapter, config);
} else {
config[key] = cacheInfo[key];
let template = configTemplates.get(cacheInfo);
if (!template) {
template = new Config();
template.applicationId = applicationId;
// for..in includes inherited keys; Config.put may store a Config instance
for (const key in cacheInfo) {
if (key !== 'databaseController') {
template[key] = cacheInfo[key];
}
}
});
template.version = version;
configTemplates.set(cacheInfo, template);
}
// Flat copy: enumeration over Config instances (specs, cloud code) must
// keep working, so per-request configs carry all keys as own properties
const config = Object.assign(new Config(), template);
if (cacheInfo.databaseController) {
config.database = new DatabaseController(cacheInfo.databaseController.adapter, config);
}
config.mount = removeTrailingSlash(mount);
config.generateSessionExpiresAt = config.generateSessionExpiresAt.bind(config);
config.generateEmailVerifyTokenExpiresAt = config.generateEmailVerifyTokenExpiresAt.bind(
config
);
config.version = version;
return config;
}

async loadKeys() {
let resolvedAny = false;
await Promise.all(
asyncKeys.map(async key => {
if (typeof this[`_${key}`] === 'function') {
try {
this[key] = await this[`_${key}`]();
resolvedAny = true;
} catch (error) {
throw new Error(`Failed to resolve async config key '${key}': ${error.message}`);
}
}
})
);
if (!resolvedAny) {
return;
}

const cachedConfig = AppCache.get(this.appId);
if (cachedConfig) {
const updatedConfig = { ...cachedConfig };
if (cachedConfig && asyncKeys.some(key => cachedConfig[key] !== this[key])) {
const updatedConfig = {};
// for..in includes inherited keys; Config.put may store a Config instance
for (const key in cachedConfig) {
updatedConfig[key] = cachedConfig[key];
}
asyncKeys.forEach(key => {
updatedConfig[key] = this[key];
});
Expand Down
2 changes: 1 addition & 1 deletion src/Controllers/DatabaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ class DatabaseController {
const originalQuery = query;
const originalUpdate = update;
// Make a copy of the object, so we don't mutate the incoming data.
update = structuredClone(update);
update = Utils.deepClone(update);
var relationUpdates = [];
var isMaster = acl === undefined;
var aclGroup = acl || [];
Expand Down
2 changes: 2 additions & 0 deletions src/Controllers/LoggerController.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export class LoggerController extends AdaptableController {
level = options.logLevel;
}
const index = logLevels.indexOf(level); // info by default
// Lets callers skip preparing log data that verbose() would silence anyway
this.verboseEnabled = logLevels.indexOf('verbose') <= index;
logLevels.forEach((level, levelIndex) => {
if (levelIndex > index) {
// silence the levels that are > maxIndex
Expand Down
46 changes: 42 additions & 4 deletions src/Controllers/SchemaController.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { StorageAdapter } from '../Adapters/Storage/StorageAdapter';
import SchemaCache from '../Adapters/Cache/SchemaCache';
import DatabaseController from './DatabaseController';
import Config from '../Config';
import AppCache from '../cache';
import Utils from '../Utils';
import { createSanitizedError } from '../Error';
import type {
Schema,
Expand Down Expand Up @@ -571,7 +573,7 @@ class SchemaData {
if (!this.__data[schema.className]) {
const data = {};
data.fields = injectDefaultSchema(schema).fields;
data.classLevelPermissions = structuredClone(schema.classLevelPermissions);
data.classLevelPermissions = Utils.deepClone(schema.classLevelPermissions);
data.indexes = schema.indexes;

const classProtectedFields = this.__protectedFields[schema.className];
Expand Down Expand Up @@ -615,6 +617,36 @@ class SchemaData {
}
}

// SchemaData per schema snapshot; SchemaCache.put stores a new array on schema
// changes, which invalidates the entry
const schemaDataCache: WeakMap<Array<Schema>, { protectedFields: any, schemaData: SchemaData }> =
new WeakMap();

const sameSchemas = (a: Array<Schema>, b: Array<Schema>) => {
if (a === b) {
return true;
}
if (a.length !== b.length) {
return false;
}
return a.every((schema, index) => schema === b[index]);
};

const getSchemaData = (allSchemas: Array<Schema>, protectedFields: any) => {
const cachedSchemas = SchemaCache.raw();
if (!cachedSchemas || !sameSchemas(cachedSchemas, allSchemas)) {
// Snapshot mismatch, e.g. a concurrent schema change; derive without caching
return new SchemaData(allSchemas, protectedFields);
}
const cached = schemaDataCache.get(cachedSchemas);
if (cached && cached.protectedFields === protectedFields) {
return cached.schemaData;
}
const schemaData = new SchemaData(allSchemas, protectedFields);
schemaDataCache.set(cachedSchemas, { protectedFields, schemaData });
return schemaData;
};

const injectDefaultSchema = ({ className, fields, classLevelPermissions, indexes }: Schema) => {
const defaultSchema: Schema = {
className,
Expand Down Expand Up @@ -719,9 +751,13 @@ export default class SchemaController {

constructor(databaseAdapter: StorageAdapter) {
this._dbAdapter = databaseAdapter;
const config = Config.get(Parse.applicationId);
this.schemaData = new SchemaData(SchemaCache.all(), this.protectedFields);
// AppCache avoids building a full Config incl. DatabaseController per request
const config = AppCache.get(Parse.applicationId);
this.protectedFields = config.protectedFields;
const cachedSchemas = SchemaCache.raw();
this.schemaData = cachedSchemas
? getSchemaData(cachedSchemas, this.protectedFields)
: new SchemaData();

const customIds = config.allowCustomObjectId;

Expand Down Expand Up @@ -757,7 +793,7 @@ export default class SchemaController {
this.reloadDataPromise = this.getAllClasses(options)
.then(
allSchemas => {
this.schemaData = new SchemaData(allSchemas, this.protectedFields);
this.schemaData = getSchemaData(allSchemas, this.protectedFields);
delete this.reloadDataPromise;
},
err => {
Expand Down Expand Up @@ -1102,6 +1138,8 @@ export default class SchemaController {
const cached = SchemaCache.get(className);
if (cached) {
cached.classLevelPermissions = perms;
// Re-put under a new array identity so cached derived data is discarded.
SchemaCache.put(SchemaCache.all());
}
}

Expand Down
13 changes: 11 additions & 2 deletions src/FileUrlValidator.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,21 @@ function validateFileUrl(fileUrl, config) {
* @throws {Parse.Error} If any File URL is not allowed.
*/
function validateFileUrlsInObject(obj, config) {
const domains = config?.fileUpload?.allowedFileUrlDomains;
if (!Array.isArray(domains) || domains.includes('*')) {
// No restriction configured; skip the recursive scan of the object.
return;
}
scanFileUrlsInObject(obj, config);
}

function scanFileUrlsInObject(obj, config) {
if (obj == null || typeof obj !== 'object') {
return;
}
if (Array.isArray(obj)) {
for (const item of obj) {
validateFileUrlsInObject(item, config);
scanFileUrlsInObject(item, config);
}
return;
}
Expand All @@ -60,7 +69,7 @@ function validateFileUrlsInObject(obj, config) {
for (const key of Object.keys(obj)) {
const value = obj[key];
if (value && typeof value === 'object') {
validateFileUrlsInObject(value, config);
scanFileUrlsInObject(value, config);
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/ParseServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { setRegexTimeout } from './LiveQuery/QueryTools';
import defaults, { DatabaseOptionDefaults } from './defaults';
import * as logging from './logger';
import Config from './Config';
import Utils from './Utils';
import PromiseRouter from './PromiseRouter';
import requiredParameter from './requiredParameter';
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
Expand Down Expand Up @@ -597,6 +598,11 @@ function injectDefaults(options: ParseServerOptions) {
}
});

// Clone: protectedFields may reference the shared option default and is
// merged into below; mutating the shared object would leak the merge across
// configurations and defeat identity-keyed caches
options.protectedFields = Utils.deepClone(options.protectedFields || {});

// Inject defaults for database options; only when no explicit database adapter is set,
// because an explicit adapter manages its own options and passing databaseOptions alongside
// it would cause a conflict error in getDatabaseController.
Expand Down
Loading
Loading