Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Fixes:
Enterprise Fixes:
- [data-manager] Fixed editing an event whose key contains `&` creating undeletable duplicate rows in the events table

Security Fixes:
- [star-rating] The `/o?method=star` ratings read now requires star-rating read access to the application it is asked about. It previously performed no authorization, so the platform and application-version combinations that had received ratings could be read for any application by a caller with no account, token or session

## Version 24.05.51

Fixes:
Expand Down
135 changes: 73 additions & 62 deletions plugins/star-rating/api/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1568,78 +1568,89 @@ function uploadFile(myfile, id, callback) {
plugins.register('/o', function(ob) {
var params = ob.params;
if (params.qstring.method === 'star') {
if (params.qstring.period) {
//check if period comes from datapicker
if (params.qstring.period.indexOf(",") !== -1) {
try {
params.qstring.period = JSON.parse(params.qstring.period);
}
catch (SyntaxError) {
common.returnMessage(params, 400, 'Bad request parameter: period');
return true;
}
}
else {
switch (params.qstring.period) {
case "prevMonth":
case "month":
case "day":
case "yesterday":
case "hour":
break;
default:
if (!/([0-9]+)days/.test(params.qstring.period)) {
//this read is app scoped: require the caller to hold star-rating
//read access on app_id, the same check the sibling reads in this
//file apply. Authorize before validating parameters so that an
//unauthorized caller cannot probe the endpoint.
validateRead(params, FEATURE_NAME, function() {
if (params.qstring.period) {
//check if period comes from datapicker
if (params.qstring.period.indexOf(",") !== -1) {
try {
params.qstring.period = JSON.parse(params.qstring.period);
}
catch (SyntaxError) {
common.returnMessage(params, 400, 'Bad request parameter: period');
return true;
}
break;
}
else {
switch (params.qstring.period) {
case "prevMonth":
case "month":
case "day":
case "yesterday":
case "hour":
break;
default:
if (!/([0-9]+)days/.test(params.qstring.period)) {
common.returnMessage(params, 400, 'Bad request parameter: period');
return true;
}
break;
}
}
}
}
else {
common.returnMessage(params, 400, 'Missing request parameter: period');
return true;
}
countlyCommon.setPeriod(params.qstring.period, true);
var periodObj = countlyCommon.periodObj;
var collectionName = 'events' + crypto.createHash('sha1').update('[CLY]_star_rating' + params.qstring.app_id).digest('hex');
var documents = [];
for (var i = 0; i < periodObj.reqZeroDbDateIds.length; i++) {
documents.push("no-segment_" + periodObj.reqZeroDbDateIds[i]);
for (var m = 0; m < common.base64.length; m++) {
documents.push("no-segment_" + periodObj.reqZeroDbDateIds[i] + "_" + common.base64[m]);
else {
common.returnMessage(params, 400, 'Missing request parameter: period');
return true;
}
}
common.db.collection(collectionName).find({
'_id': {
$in: documents
countlyCommon.setPeriod(params.qstring.period, true);
var periodObj = countlyCommon.periodObj;
var collectionName = 'events' + crypto.createHash('sha1').update('[CLY]_star_rating' + params.qstring.app_id).digest('hex');
var documents = [];
for (var i = 0; i < periodObj.reqZeroDbDateIds.length; i++) {
documents.push("no-segment_" + periodObj.reqZeroDbDateIds[i]);
for (var m = 0; m < common.base64.length; m++) {
documents.push("no-segment_" + periodObj.reqZeroDbDateIds[i] + "_" + common.base64[m]);
}
}
}).toArray(function(err, docs) {
if (!err) {
var result = {};
docs.forEach(function(doc) {
if (!doc.meta) {
doc.meta = {};
}
if (!doc.meta.platform_version_rate) {
doc.meta.platform_version_rate = [];
}
if (doc.meta_v2 && doc.meta_v2.platform_version_rate) {
common.arrayAddUniq(doc.meta.platform_version_rate, Object.keys(doc.meta_v2.platform_version_rate));
}
doc.meta.platform_version_rate.forEach(function(item) {
var data = item.split('**');
if (result[data[0]] === undefined) {
result[data[0]] = [];
common.db.collection(collectionName).find({
'_id': {
$in: documents
}
}).toArray(function(err, docs) {
if (!err) {
//A null prototype map: the keys are platform names taken from the public
//star rating event's platform_version_rate segmentation, so they can be
//"__proto__", "constructor" or "toString". On a plain object those read back
//as inherited members rather than as undefined, so the array below is never
//created and the indexOf that follows throws, failing this read for everyone.
var result = Object.create(null);
docs.forEach(function(doc) {
if (!doc.meta) {
doc.meta = {};
}
if (!doc.meta.platform_version_rate) {
doc.meta.platform_version_rate = [];
}
if (result[data[0]].indexOf(data[1]) === -1) {
result[data[0]].push(data[1]);
if (doc.meta_v2 && doc.meta_v2.platform_version_rate) {
common.arrayAddUniq(doc.meta.platform_version_rate, Object.keys(doc.meta_v2.platform_version_rate));
}
doc.meta.platform_version_rate.forEach(function(item) {
var data = item.split('**');
if (result[data[0]] === undefined) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] Use a null-prototype map for stored platform names

data[0] comes from the public star-rating event's platform_version_rate segmentation and is used as an object key. Values such as __proto__, constructor, or toString resolve inherited members on {}, so this condition does not initialize an array and the following .indexOf() throws, making the authorized ratings read fail on attacker-seeded data. Build result with Object.create(null) or use an own-property check and explicitly initialize each key. The equivalent accumulator in the alternate/granular branch needs the same treatment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, and it is a denial rather than a corruption — fixed in 2e71724.

Ran the loop rather than reasoning about it. Against a plain object, each of these throws:

TypeError: result[data[0]].indexOf is not a function

for __proto__ (reads back as Object.prototype), constructor (the Object function), and toString / valueOf / hasOwnProperty (functions). None is undefined, so the "not seen yet" branch never runs and the array is never created. One planted row therefore fails the read for every authorized caller until it ages out — and the name comes off the public star-rating event's platform_version_rate segmentation, so anyone who can write to the app chooses it.

Took the Object.create(null) option:

var result = Object.create(null);

Nothing downstream changes — the keys are still ordinary strings, and JSON.stringify serialises a null-prototype object identically, which is what returnOutput does with it. There is a test pinning that.

And the granular branch too, as you asked: the platform copy has a second accumulator over data2.data[z]._id.split('**') and it gets the same treatment. The server copies only have the one.

On the tests: they lift both the accumulator's declaration and the loop out of the real source, rather than the loop alone. That matters here — a test that built its own result would pass whichever object the shipping code used. Four of the five fail against the previous code, with the TypeError above.

result[data[0]] = [];
}
if (result[data[0]].indexOf(data[1]) === -1) {
result[data[0]].push(data[1]);
}
});
});
});
common.returnOutput(params, result);
return true;
}
common.returnOutput(params, result);
return true;
}
});
});
return true;
}
Expand Down
139 changes: 139 additions & 0 deletions test/unit-tests/star-rating.platform-grouping.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
require("should");
var fs = require("fs");
var path = require("path");

// /o/feedback/multiple/versions groups stored ratings by platform, using the platform
// name as an object key. That name arrives on the PUBLIC star-rating event, in the
// platform_version_rate segmentation, so anyone who can write to the app decides it.
//
// On a plain object, reading back a key such as "__proto__", "constructor" or "toString"
// returns an inherited member rather than undefined, so the "not seen yet" branch never
// runs, the array is never created, and the .indexOf() on the next line throws. The read
// then fails for every authorized caller until the seeded rows age out - the endpoint is
// denied by data someone else planted.
//
// The accumulation is an inline callback, so it is lifted out of the real source and run
// here. Lifting keeps the proof on the shipping code: dropping Object.create(null) breaks
// a behavioural test, not only a source match.

var API = path.join(__dirname, "../../plugins/star-rating/api/api.js");
var src = fs.readFileSync(API, "utf8");
var lines = src.split("\n");

/**
* Lift the statement starting at the first line ending with `head`, through the first
* following line whose text is `close` at the same indentation
* @param {string} head - how the statement's first line ends
* @param {string} close - the closing text, without indentation
* @returns {string} the lifted statement, left trimmed
*/
function lift(head, close) {
var start = lines.findIndex(function(l) {
return l.trimEnd().endsWith(head);
});
if (start < 0) {
throw new Error("not found in star-rating api.js: " + head);
}
var indent = lines[start].match(/^\s*/)[0];
for (var j = start + 1; j < lines.length; j++) {
if (lines[j] === indent + close) {
return lines.slice(start, j + 1).map(function(l) {
return l.slice(indent.length);
}).join("\n");
}
}
throw new Error("close not found for: " + head);
}

var LIFTED = lift("doc.meta.platform_version_rate.forEach(function(item) {", "});");

// the accumulator's declaration is lifted too, not written here: which object it is IS
// the fix, so a test that built its own would pass either way
var LIFTED_INIT = (function() {
var at = lines.findIndex(function(l) {
return l.trimEnd().endsWith("doc.meta.platform_version_rate.forEach(function(item) {");
});
for (var j = at; j >= 0; j--) {
if (/^\s*var result = .+;\s*$/.test(lines[j])) {
return lines[j].trim();
}
}
throw new Error("accumulator declaration not found in star-rating api.js");
}());

/**
* Run the real accumulation over the given rating segmentation values
* @param {Array} values - platform_version_rate entries as stored
* @returns {object} the grouping the endpoint would return
*/
function accumulate(values) {
var result;
var doc = {meta: {platform_version_rate: values}};
/* eslint-disable no-eval */
eval(LIFTED_INIT);
eval(LIFTED);
/* eslint-enable no-eval */
return result;
}

describe("star-rating platform grouping", function() {
it("builds its accumulator with a null prototype", function() {
// asserted on the source as well, because the whole defect is which object the
// accumulator is: an edit back to {} would be invisible in a diff review
var accumulators = lines.filter(function(l) {
return l.indexOf("var result = Object.create(null);") > -1;
});
accumulators.length.should.be.above(0);
lines.filter(function(l, at) {
return /^\s*var result = \{\};\s*$/.test(l)
&& lines.slice(at, at + 45).join("\n").indexOf("result[data[0]]") > -1;
}).should.eql([]);
});

it("groups ordinary platforms by name", function() {
var out = accumulate([
"Android**1.0**5**w1**",
"Android**1.1**4**w1**",
"iOS**2.0**5**w1**",
"Android**1.0**3**w1**"
]);
Object.keys(out).sort().should.eql(["Android", "iOS"]);
out.Android.should.eql(["1.0", "1.1"]);
out.iOS.should.eql(["2.0"]);
});

it("survives a platform named after a prototype member", function() {
// each of these read back as an inherited member on a plain object: an object,
// the Object function, and a function respectively. None is undefined, so the
// array was never created and .indexOf threw a TypeError on the next line.
["__proto__", "constructor", "toString", "valueOf", "hasOwnProperty"].forEach(function(name) {
var out = null;
var thrown = null;
try {
out = accumulate([name + "**1.0**5**w1**"]);
}
catch (e) {
thrown = e;
}
(thrown === null).should.equal(true, name + " threw: " + (thrown && thrown.message));
out[name].should.eql(["1.0"]);
});
});

it("still answers about the other platforms when one is seeded", function() {
// the point of the fix: one planted row must not deny the whole read
var out = accumulate([
"Android**1.0**5**w1**",
"__proto__**9.9**1**w1**",
"iOS**2.0**5**w1**"
]);
out.Android.should.eql(["1.0"]);
out.iOS.should.eql(["2.0"]);
});

it("serialises to the response the caller expects", function() {
// returnOutput stringifies it, and a null prototype object stringifies the same
var out = accumulate(["__proto__**1.0**5**w1**", "Android**1.0**5**w1**"]);
JSON.parse(JSON.stringify(out)).should.have.property("Android");
});
});
Loading