mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e42be5c526 | ||
|
|
309f64ced8 | ||
|
|
eb2952fff7 | ||
|
|
5be375dec2 | ||
|
|
ed0baa87af | ||
|
|
ba2b0a9cb9 | ||
|
|
a8aef820af | ||
|
|
af4a0417a9 | ||
|
|
852bb4782a | ||
|
|
9c414804ac | ||
|
|
ff5b39123b | ||
|
|
2aeae76d80 | ||
|
|
065141f00e | ||
|
|
0575952509 | ||
|
|
bc07a3ff3a | ||
|
|
c5cf282d11 | ||
|
|
e2333ba262 | ||
|
|
892c6f94d5 | ||
|
|
0d6f9e951d |
@@ -24,6 +24,8 @@
|
||||
- [Merging](#merging)
|
||||
- [Breaking Change](#breaking-change-1)
|
||||
- [Reverting](#reverting)
|
||||
- [Releasing](#releasing)
|
||||
- [General Considerations](#general-considerations)
|
||||
- [Major Release / Long-Term-Support](#major-release--long-term-support)
|
||||
- [Versioning](#versioning)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
@@ -379,6 +381,12 @@ If the commit reverts a previous commit, use the prefix `revert:`, followed by t
|
||||
This reverts commit 1234567890abcdef.
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
### General Considerations
|
||||
|
||||
- The `package-lock.json` file has to be deleted and recreated by npm from scratch in regular intervals using the `npm i` command. It is not enough to only update the file via automated security pull requests (e.g. dependabot, snyk), that can create inconsistencies between sub-devependencies of a dependency and increase the chances of vulnerabilities. The file should be recreated once every release cycle which is usually monthly.
|
||||
|
||||
### Major Release / Long-Term-Support
|
||||
|
||||
Long-Term-Support (LTS) is provided for the previous Parse Server major version. For example, Parse Server 4.x will receive security updates until Parse Server 5.x is superseded by Parse Server 6.x and becomes the new LTS version. While the current major version is published on branch `release`, a LTS version is published on branch `release-#.x.x`, for example `release-4.x.x` for the Parse Server 4.x LTS branch.
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
# [5.2.0-alpha.3](https://github.com/parse-community/parse-server/compare/5.2.0-alpha.2...5.2.0-alpha.3) (2022-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* security bump minimist from 1.2.5 to 1.2.6 ([#7884](https://github.com/parse-community/parse-server/issues/7884)) ([c5cf282](https://github.com/parse-community/parse-server/commit/c5cf282d11ffdc023764f8e7539a2bd6bc246fe1))
|
||||
|
||||
# [5.2.0-alpha.2](https://github.com/parse-community/parse-server/compare/5.2.0-alpha.1...5.2.0-alpha.2) (2022-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sensitive keyword detection may produce false positives ([#7881](https://github.com/parse-community/parse-server/issues/7881)) ([0d6f9e9](https://github.com/parse-community/parse-server/commit/0d6f9e951d9e186e95e96d8869066ce7022bad02))
|
||||
|
||||
# [5.2.0-alpha.1](https://github.com/parse-community/parse-server/compare/5.1.1...5.2.0-alpha.1) (2022-03-23)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# [5.2.0-beta.2](https://github.com/parse-community/parse-server/compare/5.2.0-beta.1...5.2.0-beta.2) (2022-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* security bump minimist from 1.2.5 to 1.2.6 ([#7884](https://github.com/parse-community/parse-server/issues/7884)) ([c5cf282](https://github.com/parse-community/parse-server/commit/c5cf282d11ffdc023764f8e7539a2bd6bc246fe1))
|
||||
* sensitive keyword detection may produce false positives ([#7881](https://github.com/parse-community/parse-server/issues/7881)) ([0d6f9e9](https://github.com/parse-community/parse-server/commit/0d6f9e951d9e186e95e96d8869066ce7022bad02))
|
||||
|
||||
# [5.2.0-beta.1](https://github.com/parse-community/parse-server/compare/5.1.1...5.2.0-beta.1) (2022-03-23)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
## [5.2.4](https://github.com/parse-community/parse-server/compare/5.2.3...5.2.4) (2022-06-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* protected fields exposed via LiveQuery; this removes protected fields from the client response; this may be a breaking change if your app is currently expecting to receive these protected fields ([GHSA-crrq-vr9j-fxxh](https://github.com/parse-community/parse-server/security/advisories/GHSA-crrq-vr9j-fxxh)) (https://github.com/parse-community/parse-server/pull/8074) ([#8073](https://github.com/parse-community/parse-server/issues/8073)) ([309f64c](https://github.com/parse-community/parse-server/commit/309f64ced8700321df056fb3cc97f15007a00df1))
|
||||
|
||||
## [5.2.3](https://github.com/parse-community/parse-server/compare/5.2.2...5.2.3) (2022-06-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid file request not properly handled; this fixes a security vulnerability in which an invalid file request can crash the server ([GHSA-xw6g-jjvf-wwf9](https://github.com/parse-community/parse-server/security/advisories/GHSA-xw6g-jjvf-wwf9)) ([#8060](https://github.com/parse-community/parse-server/issues/8060)) ([5be375d](https://github.com/parse-community/parse-server/commit/5be375dec2fa35425c1003ae81c55995ac72af92))
|
||||
|
||||
## [5.2.2](https://github.com/parse-community/parse-server/compare/5.2.1...5.2.2) (2022-06-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* certificate in Apple Game Center auth adapter not validated; this fixes a security vulnerability in which authentication could be bypassed using a fake certificate; if you are using the Apple Gamer Center auth adapter it is your responsibility to keep its root certificate up-to-date and we advice you read the security advisory ([GHSA-rh9j-f5f8-rvgc](https://github.com/parse-community/parse-server/security/advisories/GHSA-rh9j-f5f8-rvgc)) ([ba2b0a9](https://github.com/parse-community/parse-server/commit/ba2b0a9cb9a568817a114b132a4c2e0911d76df1))
|
||||
|
||||
## [5.2.1](https://github.com/parse-community/parse-server/compare/5.2.0...5.2.1) (2022-05-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* authentication bypass and denial of service (DoS) vulnerabilities in Apple Game Center auth adapter (GHSA-qf8x-vqjv-92gr) ([#7962](https://github.com/parse-community/parse-server/issues/7962)) ([af4a041](https://github.com/parse-community/parse-server/commit/af4a0417a9f3c1e99b3793806b4b18e04d9fa999))
|
||||
|
||||
# [5.2.0](https://github.com/parse-community/parse-server/compare/5.1.1...5.2.0) (2022-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* security bump minimist from 1.2.5 to 1.2.6 ([#7884](https://github.com/parse-community/parse-server/issues/7884)) ([c5cf282](https://github.com/parse-community/parse-server/commit/c5cf282d11ffdc023764f8e7539a2bd6bc246fe1))
|
||||
* sensitive keyword detection may produce false positives ([#7881](https://github.com/parse-community/parse-server/issues/7881)) ([0d6f9e9](https://github.com/parse-community/parse-server/commit/0d6f9e951d9e186e95e96d8869066ce7022bad02))
|
||||
|
||||
### Features
|
||||
|
||||
* improved LiveQuery error logging with additional information ([#7837](https://github.com/parse-community/parse-server/issues/7837)) ([443a509](https://github.com/parse-community/parse-server/commit/443a5099059538d379fe491793a5871fcbb4f377))
|
||||
|
||||
## [5.1.1](https://github.com/parse-community/parse-server/compare/5.1.0...5.1.1) (2022-03-18)
|
||||
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "5.2.0-beta.1",
|
||||
"version": "5.2.4",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -10329,9 +10329,9 @@
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
|
||||
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
|
||||
"dev": true
|
||||
},
|
||||
"minimist-options": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "5.2.0-beta.1",
|
||||
"version": "5.2.4",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
|
||||
+5
-6
@@ -83,21 +83,20 @@ async function config() {
|
||||
['@semantic-release/git', {
|
||||
assets: [changelogFile, 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
||||
}],
|
||||
['@semantic-release/github', {
|
||||
successComment: getReleaseComment(),
|
||||
labels: ['type:ci'],
|
||||
releasedLabels: ['state:released<%= nextRelease.channel ? `-\${nextRelease.channel}` : "" %>']
|
||||
}],
|
||||
[
|
||||
"@saithodev/semantic-release-backmerge",
|
||||
{
|
||||
"branches": [
|
||||
{ from: "beta", to: "alpha" },
|
||||
{ from: "release", to: "beta" },
|
||||
{ from: "release", to: "alpha" },
|
||||
]
|
||||
}
|
||||
],
|
||||
['@semantic-release/github', {
|
||||
successComment: getReleaseComment(),
|
||||
labels: ['type:ci'],
|
||||
releasedLabels: ['state:released<%= nextRelease.channel ? `-\${nextRelease.channel}` : "" %>']
|
||||
}],
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1652,8 +1652,41 @@ describe('apple signin auth adapter', () => {
|
||||
|
||||
describe('Apple Game Center Auth adapter', () => {
|
||||
const gcenter = require('../lib/Adapters/Auth/gcenter');
|
||||
|
||||
const fs = require('fs');
|
||||
const testCert = fs.readFileSync(__dirname + '/support/cert/game_center.pem');
|
||||
it('can load adapter', async () => {
|
||||
const options = {
|
||||
gcenter: {
|
||||
rootCertificateUrl:
|
||||
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
|
||||
},
|
||||
};
|
||||
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
|
||||
'gcenter',
|
||||
options
|
||||
);
|
||||
await adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
);
|
||||
});
|
||||
it('validateAuthData should validate', async () => {
|
||||
const options = {
|
||||
gcenter: {
|
||||
rootCertificateUrl:
|
||||
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
|
||||
},
|
||||
};
|
||||
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
|
||||
'gcenter',
|
||||
options
|
||||
);
|
||||
await adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
);
|
||||
// real token is used
|
||||
const authData = {
|
||||
id: 'G:1965586982',
|
||||
@@ -1664,68 +1697,147 @@ describe('Apple Game Center Auth adapter', () => {
|
||||
salt: 'DzqqrQ==',
|
||||
bundleId: 'cloud.xtralife.gamecenterauth',
|
||||
};
|
||||
|
||||
try {
|
||||
await gcenter.validateAuthData(authData);
|
||||
} catch (e) {
|
||||
fail();
|
||||
}
|
||||
gcenter.cache['https://static.gc.apple.com/public-key/gc-prod-4.cer'] = testCert;
|
||||
await gcenter.validateAuthData(authData);
|
||||
});
|
||||
|
||||
it('validateAuthData invalid signature id', async () => {
|
||||
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
|
||||
'gcenter',
|
||||
{}
|
||||
);
|
||||
await adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
);
|
||||
const authData = {
|
||||
id: 'G:1965586982',
|
||||
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
|
||||
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-6.cer',
|
||||
timestamp: 1565257031287,
|
||||
signature: '1234',
|
||||
salt: 'DzqqrQ==',
|
||||
bundleId: 'cloud.xtralife.gamecenterauth',
|
||||
bundleId: 'com.example.com',
|
||||
};
|
||||
|
||||
try {
|
||||
await gcenter.validateAuthData(authData);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Apple Game Center - invalid signature');
|
||||
}
|
||||
});
|
||||
|
||||
it('validateAuthData invalid public key url', async () => {
|
||||
const authData = {
|
||||
id: 'G:1965586982',
|
||||
publicKeyUrl: 'invalid.com',
|
||||
timestamp: 1565257031287,
|
||||
signature: '1234',
|
||||
salt: 'DzqqrQ==',
|
||||
bundleId: 'cloud.xtralife.gamecenterauth',
|
||||
};
|
||||
|
||||
try {
|
||||
await gcenter.validateAuthData(authData);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Apple Game Center - invalid publicKeyUrl: invalid.com');
|
||||
}
|
||||
await expectAsync(gcenter.validateAuthData(authData)).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Apple Game Center - invalid signature')
|
||||
);
|
||||
});
|
||||
|
||||
it('validateAuthData invalid public key http url', async () => {
|
||||
const authData = {
|
||||
id: 'G:1965586982',
|
||||
publicKeyUrl: 'http://static.gc.apple.com/public-key/gc-prod-4.cer',
|
||||
timestamp: 1565257031287,
|
||||
signature: '1234',
|
||||
salt: 'DzqqrQ==',
|
||||
bundleId: 'cloud.xtralife.gamecenterauth',
|
||||
const options = {
|
||||
gcenter: {
|
||||
rootCertificateUrl:
|
||||
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
|
||||
},
|
||||
};
|
||||
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
|
||||
'gcenter',
|
||||
options
|
||||
);
|
||||
await adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
);
|
||||
const publicKeyUrls = [
|
||||
'example.com',
|
||||
'http://static.gc.apple.com/public-key/gc-prod-4.cer',
|
||||
'https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg',
|
||||
'https://example.com/ \\.apple.com/public_key.cer',
|
||||
'https://example.com/ &.apple.com/public_key.cer',
|
||||
];
|
||||
await Promise.all(
|
||||
publicKeyUrls.map(publicKeyUrl =>
|
||||
expectAsync(
|
||||
gcenter.validateAuthData({
|
||||
id: 'G:1965586982',
|
||||
timestamp: 1565257031287,
|
||||
publicKeyUrl,
|
||||
signature: '1234',
|
||||
salt: 'DzqqrQ==',
|
||||
bundleId: 'com.example.com',
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(
|
||||
Parse.Error.SCRIPT_FAILED,
|
||||
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await gcenter.validateAuthData(authData);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'Apple Game Center - invalid publicKeyUrl: http://static.gc.apple.com/public-key/gc-prod-4.cer'
|
||||
);
|
||||
}
|
||||
it('should not validate Symantec Cert', async () => {
|
||||
const options = {
|
||||
gcenter: {
|
||||
rootCertificateUrl:
|
||||
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
|
||||
},
|
||||
};
|
||||
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
|
||||
'gcenter',
|
||||
options
|
||||
);
|
||||
await adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
);
|
||||
expect(() =>
|
||||
gcenter.verifyPublicKeyIssuer(
|
||||
testCert,
|
||||
'https://static.gc.apple.com/public-key/gc-prod-4.cer'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('adapter should load default cert', async () => {
|
||||
const options = {
|
||||
gcenter: {},
|
||||
};
|
||||
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
|
||||
'gcenter',
|
||||
options
|
||||
);
|
||||
await adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
);
|
||||
const previous = new Date();
|
||||
await adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
);
|
||||
|
||||
const duration = new Date().getTime() - previous.getTime();
|
||||
expect(duration).toEqual(0);
|
||||
});
|
||||
|
||||
it('adapter should throw', async () => {
|
||||
const options = {
|
||||
gcenter: {
|
||||
rootCertificateUrl: 'https://example.com',
|
||||
},
|
||||
};
|
||||
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
|
||||
'gcenter',
|
||||
options
|
||||
);
|
||||
await expectAsync(
|
||||
adapter.validateAppId(
|
||||
appIds,
|
||||
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
|
||||
providerOptions
|
||||
)
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -654,6 +654,44 @@ describe('Parse.File testing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getting files', () => {
|
||||
it('does not crash on file request with invalid app ID', async () => {
|
||||
const res1 = await request({
|
||||
url: 'http://localhost:8378/1/files/invalid-id/invalid-file.txt',
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(403);
|
||||
expect(res1.data).toEqual({ code: 119, error: 'Invalid application ID.' });
|
||||
// Ensure server did not crash
|
||||
const res2 = await request({ url: 'http://localhost:8378/1/health' });
|
||||
expect(res2.status).toEqual(200);
|
||||
expect(res2.data).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('does not crash on file request with invalid path', async () => {
|
||||
const res1 = await request({
|
||||
url: 'http://localhost:8378/1/files/invalid-id//invalid-path/%20/invalid-file.txt',
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(403);
|
||||
expect(res1.data).toEqual({ error: 'unauthorized' });
|
||||
// Ensure server did not crash
|
||||
const res2 = await request({ url: 'http://localhost:8378/1/health' });
|
||||
expect(res2.status).toEqual(200);
|
||||
expect(res2.data).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('does not crash on file metadata request with invalid app ID', async () => {
|
||||
const res1 = await request({
|
||||
url: `http://localhost:8378/1/files/invalid-id/metadata/invalid-file.txt`,
|
||||
});
|
||||
expect(res1.status).toBe(200);
|
||||
expect(res1.data).toEqual({});
|
||||
// Ensure server did not crash
|
||||
const res2 = await request({ url: 'http://localhost:8378/1/health' });
|
||||
expect(res2.status).toEqual(200);
|
||||
expect(res2.data).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
xdescribe('Gridstore Range tests', () => {
|
||||
it('supports range requests', done => {
|
||||
const headers = {
|
||||
|
||||
@@ -1066,6 +1066,52 @@ describe('ParseLiveQuery', function () {
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip out protected fields', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['Test'] },
|
||||
startLiveQueryServer: true,
|
||||
});
|
||||
const obj1 = new Parse.Object('Test');
|
||||
obj1.set('foo', 'foo');
|
||||
obj1.set('bar', 'bar');
|
||||
obj1.set('qux', 'qux');
|
||||
await obj1.save();
|
||||
const config = Config.get(Parse.applicationId);
|
||||
const schemaController = await config.database.loadSchema();
|
||||
await schemaController.updateClass(
|
||||
'Test',
|
||||
{},
|
||||
{
|
||||
get: { '*': true },
|
||||
find: { '*': true },
|
||||
update: { '*': true },
|
||||
protectedFields: {
|
||||
'*': ['foo'],
|
||||
},
|
||||
}
|
||||
);
|
||||
const object = await obj1.fetch();
|
||||
expect(object.get('foo')).toBe(undefined);
|
||||
expect(object.get('bar')).toBeDefined();
|
||||
expect(object.get('qux')).toBeDefined();
|
||||
|
||||
const subscription = await new Parse.Query('Test').subscribe();
|
||||
await Promise.all([
|
||||
new Promise(resolve => {
|
||||
subscription.on('update', (obj, original) => {
|
||||
expect(obj.get('foo')).toBe(undefined);
|
||||
expect(obj.get('bar')).toBeDefined();
|
||||
expect(obj.get('qux')).toBeDefined();
|
||||
expect(original.get('foo')).toBe(undefined);
|
||||
expect(original.get('bar')).toBeDefined();
|
||||
expect(original.get('qux')).toBeDefined();
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
obj1.save({ foo: 'abc' }),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async function (done) {
|
||||
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
|
||||
client.close();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEvDCCA6SgAwIBAgIQXRHxNXkw1L9z5/3EZ/T/hDANBgkqhkiG9w0BAQsFADB/
|
||||
MQswCQYDVQQGEwJVUzEdMBsGA1UEChMUU3ltYW50ZWMgQ29ycG9yYXRpb24xHzAd
|
||||
BgNVBAsTFlN5bWFudGVjIFRydXN0IE5ldHdvcmsxMDAuBgNVBAMTJ1N5bWFudGVj
|
||||
IENsYXNzIDMgU0hBMjU2IENvZGUgU2lnbmluZyBDQTAeFw0xODA5MTcwMDAwMDBa
|
||||
Fw0xOTA5MTcyMzU5NTlaMHMxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9y
|
||||
bmlhMRIwEAYDVQQHDAlDdXBlcnRpbm8xFDASBgNVBAoMC0FwcGxlLCBJbmMuMQ8w
|
||||
DQYDVQQLDAZHQyBTUkUxFDASBgNVBAMMC0FwcGxlLCBJbmMuMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA06fwIi8fgKrTQu7cBcFkJVF6+Tqvkg7MKJTM
|
||||
IOYPPQtPF3AZYPsbUoRKAD7/JXrxxOSVJ7vU1mP77tYG8TcUteZ3sAwvt2dkRbm7
|
||||
ZO6DcmSggv1Dg4k3goNw4GYyCY4Z2/8JSmsQ80Iv/UOOwynpBziEeZmJ4uck6zlA
|
||||
17cDkH48LBpKylaqthym5bFs9gj11pto7mvyb5BTcVuohwi6qosvbs/4VGbC2Nsz
|
||||
ie416nUZfv+xxoXH995gxR2mw5cDdeCew7pSKxEhvYjT2nVdQF0q/hnPMFnOaEyT
|
||||
q79n3gwFXyt0dy8eP6KBF7EW9J6b7ubu/j7h+tQfxPM+gTXOBQIDAQABo4IBPjCC
|
||||
ATowCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUH
|
||||
AwMwYQYDVR0gBFowWDBWBgZngQwBBAEwTDAjBggrBgEFBQcCARYXaHR0cHM6Ly9k
|
||||
LnN5bWNiLmNvbS9jcHMwJQYIKwYBBQUHAgIwGQwXaHR0cHM6Ly9kLnN5bWNiLmNv
|
||||
bS9ycGEwHwYDVR0jBBgwFoAUljtT8Hkzl699g+8uK8zKt4YecmYwKwYDVR0fBCQw
|
||||
IjAgoB6gHIYaaHR0cDovL3N2LnN5bWNiLmNvbS9zdi5jcmwwVwYIKwYBBQUHAQEE
|
||||
SzBJMB8GCCsGAQUFBzABhhNodHRwOi8vc3Yuc3ltY2QuY29tMCYGCCsGAQUFBzAC
|
||||
hhpodHRwOi8vc3Yuc3ltY2IuY29tL3N2LmNydDANBgkqhkiG9w0BAQsFAAOCAQEA
|
||||
I/j/PcCNPebSAGrcqSFBSa2mmbusOX01eVBg8X0G/z8Z+ZWUfGFzDG0GQf89MPxV
|
||||
woec+nZuqui7o9Bg8s8JbHV0TC52X14CbTj9w/qBF748WbH9gAaTkrJYPm+MlNhu
|
||||
tjEuQdNl/YXVMvQW4O8UMHTi09GyJQ0NC4q92Wxvx1m/qzjvTLvrXHGQ9pEHhPyz
|
||||
vfBLxQkWpNoCNKU7UeESyH06XOrGc9MsII9deeKsDJp9a0jtx+pP4MFVtFME9SSQ
|
||||
tMBs0It7WwEf7qcRLpialxKwY2EzQ9g4WnANHqo18PrDBE10TFpZPzUh7JhMViVr
|
||||
EEbl0YdElmF8Hlamah/yNw==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -280,4 +280,18 @@ describe('Vulnerabilities', () => {
|
||||
expect(text.error).toBe('Prohibited keyword in request data: {"value":"aValue[123]*"}.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ignore non-matches', () => {
|
||||
it('ignores write request that contains only fraction of denied keyword', async () => {
|
||||
await reconfigureServer({
|
||||
requestKeywordDenylist: [{ key: 'abc' }],
|
||||
});
|
||||
// Initially saving an object executes the keyword detection in RestWrite.js
|
||||
const obj = new TestObject({ a: { b: { c: 0 } } });
|
||||
await expectAsync(obj.save()).toBeResolved();
|
||||
// Modifying a nested key executes the keyword detection in DatabaseController.js
|
||||
obj.increment('a.b.c');
|
||||
await expectAsync(obj.save()).toBeResolved();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,20 +14,14 @@ const authData = {
|
||||
const { Parse } = require('parse/node');
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
|
||||
const { pki } = require('node-forge');
|
||||
const ca = { cert: null, url: null };
|
||||
const cache = {}; // (publicKey -> cert) cache
|
||||
|
||||
function verifyPublicKeyUrl(publicKeyUrl) {
|
||||
try {
|
||||
const parsedUrl = new URL(publicKeyUrl);
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
const hostnameParts = parsedUrl.hostname.split('.');
|
||||
const length = hostnameParts.length;
|
||||
const domainParts = hostnameParts.slice(length - 2, length);
|
||||
const domain = domainParts.join('.');
|
||||
return domain === 'apple.com';
|
||||
const regex = /^https:\/\/(?:[-_A-Za-z0-9]+\.){0,}apple\.com\/.*\.cer$/;
|
||||
return regex.test(publicKeyUrl);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
@@ -43,7 +37,7 @@ function convertX509CertToPEM(X509Cert) {
|
||||
return pemPreFix + certBody + pemPostFix;
|
||||
}
|
||||
|
||||
function getAppleCertificate(publicKeyUrl) {
|
||||
async function getAppleCertificate(publicKeyUrl) {
|
||||
if (!verifyPublicKeyUrl(publicKeyUrl)) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
@@ -53,26 +47,59 @@ function getAppleCertificate(publicKeyUrl) {
|
||||
if (cache[publicKeyUrl]) {
|
||||
return cache[publicKeyUrl];
|
||||
}
|
||||
const url = new URL(publicKeyUrl);
|
||||
const headOptions = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: 'HEAD',
|
||||
};
|
||||
const cert_headers = await new Promise((resolve, reject) =>
|
||||
https.get(headOptions, res => resolve(res.headers)).on('error', reject)
|
||||
);
|
||||
const validContentTypes = ['application/x-x509-ca-cert', 'application/pkix-cert'];
|
||||
if (
|
||||
!validContentTypes.includes(cert_headers['content-type']) ||
|
||||
cert_headers['content-length'] == null ||
|
||||
cert_headers['content-length'] > 10000
|
||||
) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
||||
);
|
||||
}
|
||||
const { certificate, headers } = await getCertificate(publicKeyUrl);
|
||||
if (headers['cache-control']) {
|
||||
const expire = headers['cache-control'].match(/max-age=([0-9]+)/);
|
||||
if (expire) {
|
||||
cache[publicKeyUrl] = certificate;
|
||||
// we'll expire the cache entry later, as per max-age
|
||||
setTimeout(() => {
|
||||
delete cache[publicKeyUrl];
|
||||
}, parseInt(expire[1], 10) * 1000);
|
||||
}
|
||||
}
|
||||
return verifyPublicKeyIssuer(certificate, publicKeyUrl);
|
||||
}
|
||||
|
||||
function getCertificate(url, buffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(publicKeyUrl, res => {
|
||||
let data = '';
|
||||
.get(url, res => {
|
||||
const data = [];
|
||||
res.on('data', chunk => {
|
||||
data += chunk.toString('base64');
|
||||
data.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const cert = convertX509CertToPEM(data);
|
||||
if (res.headers['cache-control']) {
|
||||
var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/);
|
||||
if (expire) {
|
||||
cache[publicKeyUrl] = cert;
|
||||
// we'll expire the cache entry later, as per max-age
|
||||
setTimeout(() => {
|
||||
delete cache[publicKeyUrl];
|
||||
}, parseInt(expire[1], 10) * 1000);
|
||||
}
|
||||
if (buffer) {
|
||||
resolve({ certificate: Buffer.concat(data), headers: res.headers });
|
||||
return;
|
||||
}
|
||||
resolve(cert);
|
||||
let cert = '';
|
||||
for (const chunk of data) {
|
||||
cert += chunk.toString('base64');
|
||||
}
|
||||
const certificate = convertX509CertToPEM(cert);
|
||||
resolve({ certificate, headers: res.headers });
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
@@ -103,6 +130,30 @@ function verifySignature(publicKey, authData) {
|
||||
}
|
||||
}
|
||||
|
||||
function verifyPublicKeyIssuer(cert, publicKeyUrl) {
|
||||
const publicKeyCert = pki.certificateFromPem(cert);
|
||||
if (!ca.cert) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (!ca.cert.verify(publicKeyCert)) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
|
||||
);
|
||||
}
|
||||
return cert;
|
||||
}
|
||||
|
||||
// Returns a promise that fulfills if this user id is valid.
|
||||
async function validateAuthData(authData) {
|
||||
if (!authData.id) {
|
||||
@@ -114,11 +165,31 @@ async function validateAuthData(authData) {
|
||||
}
|
||||
|
||||
// Returns a promise that fulfills if this app id is valid.
|
||||
function validateAppId() {
|
||||
return Promise.resolve();
|
||||
async function validateAppId(appIds, authData, options = {}) {
|
||||
if (!options.rootCertificateUrl) {
|
||||
options.rootCertificateUrl =
|
||||
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem';
|
||||
}
|
||||
if (ca.url === options.rootCertificateUrl) {
|
||||
return;
|
||||
}
|
||||
const { certificate, headers } = await getCertificate(options.rootCertificateUrl, true);
|
||||
if (
|
||||
headers['content-type'] !== 'application/x-pem-file' ||
|
||||
headers['content-length'] == null ||
|
||||
headers['content-length'] > 10000
|
||||
) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
|
||||
);
|
||||
}
|
||||
ca.cert = pki.certificateFromPem(certificate);
|
||||
ca.url = options.rootCertificateUrl;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateAppId,
|
||||
validateAuthData,
|
||||
cache,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import intersect from 'intersect';
|
||||
// @flow-disable-next
|
||||
import deepcopy from 'deepcopy';
|
||||
import logger from '../logger';
|
||||
import Utils from '../Utils';
|
||||
import * as SchemaController from './SchemaController';
|
||||
import { StorageAdapter } from '../Adapters/Storage/StorageAdapter';
|
||||
import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter';
|
||||
@@ -126,7 +127,7 @@ const filterSensitiveData = (
|
||||
aclGroup: any[],
|
||||
auth: any,
|
||||
operation: any,
|
||||
schema: SchemaController.SchemaController,
|
||||
schema: SchemaController.SchemaController | any,
|
||||
className: string,
|
||||
protectedFields: null | Array<any>,
|
||||
object: any
|
||||
@@ -135,7 +136,8 @@ const filterSensitiveData = (
|
||||
if (auth && auth.user) userId = auth.user.id;
|
||||
|
||||
// replace protectedFields when using pointer-permissions
|
||||
const perms = schema.getClassLevelPermissions(className);
|
||||
const perms =
|
||||
schema && schema.getClassLevelPermissions ? schema.getClassLevelPermissions(className) : {};
|
||||
if (perms) {
|
||||
const isReadOperation = ['get', 'find'].indexOf(operation) > -1;
|
||||
|
||||
@@ -1532,14 +1534,17 @@ class DatabaseController {
|
||||
}
|
||||
|
||||
addProtectedFields(
|
||||
schema: SchemaController.SchemaController,
|
||||
schema: SchemaController.SchemaController | any,
|
||||
className: string,
|
||||
query: any = {},
|
||||
aclGroup: any[] = [],
|
||||
auth: any = {},
|
||||
queryOptions: FullQueryOptions = {}
|
||||
): null | string[] {
|
||||
const perms = schema.getClassLevelPermissions(className);
|
||||
const perms =
|
||||
schema && schema.getClassLevelPermissions
|
||||
? schema.getClassLevelPermissions(className)
|
||||
: schema;
|
||||
if (!perms) return null;
|
||||
|
||||
const protectedFields = perms.protectedFields;
|
||||
@@ -1763,8 +1768,8 @@ class DatabaseController {
|
||||
if (this.options && this.options.requestKeywordDenylist) {
|
||||
// Scan request data for denied keywords
|
||||
for (const keyword of this.options.requestKeywordDenylist) {
|
||||
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(a).test(b)) || a === b;
|
||||
if (isMatch(firstKey, keyword.key)) {
|
||||
const match = Utils.objectContainsKeyValue({ firstKey: undefined }, keyword.key, undefined);
|
||||
if (match) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.INVALID_KEY_NAME,
|
||||
`Prohibited keyword in request data: ${JSON.stringify(keyword)}.`
|
||||
@@ -1805,8 +1810,10 @@ class DatabaseController {
|
||||
}
|
||||
|
||||
static _validateQuery: any => void;
|
||||
static filterSensitiveData: (boolean, any[], any, any, any, string, any[], any) => void;
|
||||
}
|
||||
|
||||
module.exports = DatabaseController;
|
||||
// Expose validateQuery for tests
|
||||
module.exports._validateQuery = validateQuery;
|
||||
module.exports.filterSensitiveData = filterSensitiveData;
|
||||
|
||||
@@ -33,6 +33,9 @@ class ParseCloudCodePublisher {
|
||||
if (request.original) {
|
||||
message.originalParseObject = request.original._toFullJSON();
|
||||
}
|
||||
if (request.classLevelPermissions) {
|
||||
message.classLevelPermissions = request.classLevelPermissions;
|
||||
}
|
||||
this.parsePublisher.publish(type, JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,18 @@ import { ParsePubSub } from './ParsePubSub';
|
||||
import SchemaController from '../Controllers/SchemaController';
|
||||
import _ from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { runLiveQueryEventHandlers, getTrigger, runTrigger, resolveError, toJSONwithObjects } from '../triggers';
|
||||
import {
|
||||
runLiveQueryEventHandlers,
|
||||
getTrigger,
|
||||
runTrigger,
|
||||
resolveError,
|
||||
toJSONwithObjects,
|
||||
} from '../triggers';
|
||||
import { getAuthForSessionToken, Auth } from '../Auth';
|
||||
import { getCacheController } from '../Controllers';
|
||||
import { getCacheController, getDatabaseController } from '../Controllers';
|
||||
import LRU from 'lru-cache';
|
||||
import UserRouter from '../Routers/UsersRouter';
|
||||
import DatabaseController from '../Controllers/DatabaseController';
|
||||
|
||||
class ParseLiveQueryServer {
|
||||
clients: Map;
|
||||
@@ -185,14 +192,14 @@ class ParseLiveQueryServer {
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
if (
|
||||
(deletedParseObject.className === '_User' ||
|
||||
deletedParseObject.className === '_Session') &&
|
||||
!client.hasMasterKey
|
||||
) {
|
||||
delete deletedParseObject.sessionToken;
|
||||
delete deletedParseObject.authData;
|
||||
}
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
client,
|
||||
requestId,
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
@@ -339,16 +346,14 @@ class ParseLiveQueryServer {
|
||||
res.original.className || className
|
||||
);
|
||||
}
|
||||
if (
|
||||
(currentParseObject.className === '_User' ||
|
||||
currentParseObject.className === '_Session') &&
|
||||
!client.hasMasterKey
|
||||
) {
|
||||
delete currentParseObject.sessionToken;
|
||||
delete originalParseObject?.sessionToken;
|
||||
delete currentParseObject.authData;
|
||||
delete originalParseObject?.authData;
|
||||
}
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
client,
|
||||
requestId,
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
|
||||
if (client[functionName]) {
|
||||
client[functionName](requestId, currentParseObject, originalParseObject);
|
||||
@@ -540,6 +545,54 @@ class ParseLiveQueryServer {
|
||||
// return rolesQuery.find({useMasterKey:true});
|
||||
}
|
||||
|
||||
async _filterSensitiveData(
|
||||
classLevelPermissions: ?any,
|
||||
res: any,
|
||||
client: any,
|
||||
requestId: number,
|
||||
op: string,
|
||||
query: any
|
||||
) {
|
||||
const subscriptionInfo = client.getSubscriptionInfo(requestId);
|
||||
const aclGroup = ['*'];
|
||||
let clientAuth;
|
||||
if (typeof subscriptionInfo !== 'undefined') {
|
||||
const { userId, auth } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken);
|
||||
if (userId) {
|
||||
aclGroup.push(userId);
|
||||
}
|
||||
clientAuth = auth;
|
||||
}
|
||||
const filter = obj => {
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
let protectedFields = classLevelPermissions?.protectedFields || [];
|
||||
if (!client.hasMasterKey && !Array.isArray(protectedFields)) {
|
||||
protectedFields = getDatabaseController(this.config).addProtectedFields(
|
||||
classLevelPermissions,
|
||||
res.object.className,
|
||||
query,
|
||||
aclGroup,
|
||||
clientAuth
|
||||
);
|
||||
}
|
||||
return DatabaseController.filterSensitiveData(
|
||||
client.hasMasterKey,
|
||||
aclGroup,
|
||||
clientAuth,
|
||||
op,
|
||||
classLevelPermissions,
|
||||
res.object.className,
|
||||
protectedFields,
|
||||
obj,
|
||||
query
|
||||
);
|
||||
};
|
||||
res.object = filter(res.object);
|
||||
res.original = filter(res.original);
|
||||
}
|
||||
|
||||
_getCLPOperation(query: any) {
|
||||
return typeof query === 'object' &&
|
||||
Object.keys(query).length == 1 &&
|
||||
|
||||
@@ -66,6 +66,12 @@ export class FilesRouter {
|
||||
|
||||
getHandler(req, res) {
|
||||
const config = Config.get(req.params.appId);
|
||||
if (!config) {
|
||||
res.status(403);
|
||||
const err = new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Invalid application ID.');
|
||||
res.json({ code: err.code, error: err.message });
|
||||
return;
|
||||
}
|
||||
const filesController = config.filesController;
|
||||
const filename = req.params.filename;
|
||||
const contentType = mime.getType(filename);
|
||||
@@ -250,10 +256,10 @@ export class FilesRouter {
|
||||
}
|
||||
|
||||
async metadataHandler(req, res) {
|
||||
const config = Config.get(req.params.appId);
|
||||
const { filesController } = config;
|
||||
const { filename } = req.params;
|
||||
try {
|
||||
const config = Config.get(req.params.appId);
|
||||
const { filesController } = config;
|
||||
const { filename } = req.params;
|
||||
const data = await filesController.getMetadata(filename);
|
||||
res.status(200);
|
||||
res.json(data);
|
||||
|
||||
+3
-3
@@ -341,9 +341,9 @@ class Utils {
|
||||
* @returns {Boolean} True if a match was found, false otherwise.
|
||||
*/
|
||||
static objectContainsKeyValue(obj, key, value) {
|
||||
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(a).test(b)) || a === b;
|
||||
const isKeyMatch = k => isMatch(key, k);
|
||||
const isValueMatch = v => isMatch(value, v);
|
||||
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b;
|
||||
const isKeyMatch = k => isMatch(k, key);
|
||||
const isValueMatch = v => isMatch(v, value);
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (key !== undefined && value === undefined && isKeyMatch(k)) {
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user