This document outlines security requirements and best practices for Countly Server development. All contributors must follow these guidelines to ensure the security of the platform.
- API Endpoint Security
- Cross-App Operation Security
- XSS Prevention
- MongoDB Injection Prevention
- File Upload Security
- Command Line Security
- CSV Injection Prevention
- Brute Force Prevention
All API endpoints (except special cases) must be secured using validation methods from api/utils/rights.js.
| Method | Purpose | Required Params |
|---|---|---|
validateUser |
Verify user exists | api_key or auth_token |
validateRead |
Check read permission on feature | api_key, app_id |
validateCreate |
Check create permission on feature | api_key, app_id |
validateUpdate |
Check update permission on feature | api_key, app_id |
validateDelete |
Check delete permission on feature | api_key, app_id |
validateGlobalAdmin |
Check global admin status | api_key |
dbUserHasAccessToCollection |
Check collection-level access | member, app_id |
const { validateRead, validateUpdate, validateDelete, validateGlobalAdmin } = require('../../../api/utils/rights.js');
// Read permission check
plugins.register("/o/myfeature", function(ob) {
var params = ob.params;
validateRead(params, 'myfeature', function() {
// User has read access to this feature for the app
common.returnOutput(params, data);
});
});
// Write permission check
plugins.register("/i/myfeature/update", function(ob) {
var params = ob.params;
validateUpdate(params, 'myfeature', function() {
// User has update permission
performUpdate(params);
});
});
// Global admin check (no app context)
plugins.register("/i/admin/settings", function(ob) {
var params = ob.params;
validateGlobalAdmin(params, function() {
// User is a global administrator
updateGlobalSettings(params);
});
});For endpoints that expose entire collections (like data export):
const { dbUserHasAccessToCollection } = require('../../../api/utils/rights.js');
validateRead(params, 'core', function() {
dbUserHasAccessToCollection(params, params.qstring.collection, function(hasAccess) {
if (hasAccess) {
exportData(params);
} else {
common.returnMessage(params, 401, 'User does not have access to this collection');
}
});
});Critical: All edit/delete operations must verify the resource belongs to the authorized app.
// DANGEROUS: User can manipulate resources from other apps
validateDelete(params, 'cohorts', function() {
db.collection("cohorts").deleteOne({_id: params.qstring.id});
});An attacker could:
- Get delete permission for App A
- Provide a cohort ID from App B
- Delete App B's cohort without authorization
// SAFE: Verify resource belongs to authorized app
validateDelete(params, 'cohorts', function() {
db.collection("cohorts").deleteOne({
_id: params.qstring.id,
app_id: params.app_id + "" // Cast to string for consistency
});
});This applies to all operations:
deleteOne/deleteManyupdateOne/updateManyfindOneAndUpdate/findOneAndDelete
API responses are automatically escaped when using standard output methods:
// ✅ Auto-escaped - safe to use
common.returnOutput(params, data);
common.returnMessage(params, 200, 'Success');For custom output, manually escape these characters:
"→"&→&'→'<→<>→>
Use the built-in escape function:
var safeString = common.escape_html(unsafeString);Data from API should be rendered as text (Vue will escape it):
<!-- ✅ Correct: Render API-provided text safely -->
<p>{{ apiData.description }}</p>User input that bypasses API must be escaped as text:
<!-- ✅ Safe: Vue automatically escapes in text interpolation -->
<span>{{ userInput }}</span>Never use v-html with raw user input:
<!-- ❌ DANGEROUS: XSS vulnerability -->
<div v-html="userProvidedContent"></div>// Frontend sanitization
var sanitized = countlyCommon.encodeHtml(userInput);
// Test inputs to verify XSS protection
var testString = "<script>'&&&'</script>";
// Should display exactly as: <script>'&&&'</script>MongoDB operations using the official driver are generally safe from code injection. However, data manipulation attacks are still possible.
// User submits: {"username": "admin", "password": {"$ne": 1}}
var params = {
username: "admin",
password: {"$ne": 1} // Matches any password not equal to 1
};
db.collection("members").findOne(params, function(err, user) {
if (!err && user) {
// ATTACKER AUTHENTICATED without knowing password!
}
});Always cast authentication credentials to strings:
// ✅ Safe: Force string type
params.username = params.username + "";
params.password = params.password + "";
db.collection("members").findOne({
username: params.username,
password: params.password
}, function(err, user) {
// Now safe from object injection
});For objects, validate no MongoDB operators are present:
function isSafeQuery(obj) {
for (var key in obj) {
if (key.startsWith('$')) {
return false; // MongoDB operator detected
}
if (typeof obj[key] === 'object' && obj[key] !== null && !isSafeQuery(obj[key])) {
return false;
}
}
return true;
}var tmp_path = params.files.upload.path;
var type = params.files.upload.type;
// Whitelist allowed types
var allowedTypes = ["image/png", "image/gif", "image/jpeg"];
if (!allowedTypes.includes(type)) {
// Delete the uploaded file
fs.unlink(tmp_path, function() {});
common.returnMessage(params, 400, 'Invalid file type');
return;
}
// Additional: Verify file magic bytes match claimed type
// (type header can be spoofed)Never use user-provided filenames directly:
// ❌ Dangerous: Path traversal possible
// User provides: "../../../etc/passwd"
var filename = params.qstring.filename;
fs.writeFile('/uploads/' + filename, data);
// ✅ Safe: Sanitize filename
var safeFileName = common.sanitizeFilename(params.qstring.filename);
fs.writeFile('/uploads/' + safeFileName, data);The sanitizeFilename function:
- Removes path separators (
/,\) - Removes null bytes
- Limits length
- Removes dangerous characters
var exec = require('child_process').exec;
// ❌ DANGEROUS: Command injection
var scriptPath = userInput; // User provides: "myscript.js; rm -rf /"
exec("nodejs " + scriptPath, callback);
// Executes: nodejs myscript.js; rm -rf /Use spawn with argument arrays:
var cp = require('child_process');
// ✅ Safe: Arguments are properly escaped
var scriptPath = userInput; // Even if: "myscript.js; rm -rf /"
var process = cp.spawn("nodejs", [scriptPath]);
process.on('close', function(code) {
console.log('Exited with code:', code);
});
// The malicious input is treated as a literal filename
// nodejs will fail to find file named "myscript.js; rm -rf /"If you must use exec, sanitize rigorously:
var shellEscape = require('shell-escape');
var safeArgs = shellEscape([userInput]);
exec("nodejs " + safeArgs, callback);When exporting data to CSV or Excel, cell values starting with special characters can be interpreted as formulas.
A malicious user stores data like:
=cmd|' /C calc'!A0
When exported to CSV and opened in Excel, this launches the calculator (or worse).
var exports = require('../../../api/parts/data/exports.js');
// Use the built-in function
var safeValue = exports.preventCSVInjection(cellValue);
// Or manually prefix dangerous characters
function preventCSVInjection(value) {
if (typeof value === 'string') {
var dangerous = ['=', '+', '-', '@', '\t', '\r'];
if (dangerous.includes(value.charAt(0))) {
return "'" + value; // Prefix with single quote
}
}
return value;
}Protect authentication endpoints from brute force attacks.
var preventBruteforce = require('../../../frontend/express/libs/preventBruteforce.js');
function login(req, res) {
var username = req.body.username;
var password = req.body.password;
preventBruteforce.isBlocked("login", username, function(isBlocked, fails, err) {
if (isBlocked) {
res.status(429).json({error: "Too many failed attempts. Please try again later."});
return;
}
authenticateUser(username, password, function(success) {
if (success) {
// Reset fail counter on successful login
preventBruteforce.reset("login", username);
// ... complete login
} else {
// Increment fail counter
preventBruteforce.fail("login", username);
res.status(401).json({error: "Invalid credentials"});
}
});
});
}API endpoints can use rate limiting:
const { RateLimiterMemory } = require("rate-limiter-flexible");
const rateLimiter = new RateLimiterMemory({
points: 10, // 10 requests
duration: 1, // per 1 second
});
async function handleRequest(params) {
try {
await rateLimiter.consume(params.ip_address);
// Process request
} catch (rejRes) {
common.returnMessage(params, 429, "Too Many Requests");
}
}Before submitting code, verify:
- All API endpoints use appropriate validation methods
- All database operations include
app_idwhere applicable - User credentials are cast to strings
- File uploads validate type and sanitize filename
- Command line arguments use
spawnwith arrays - CSV exports use injection prevention
- Authentication endpoints have brute force protection
- No
v-htmlwith unsanitized user input - Sensitive data is not logged
If you discover a security vulnerability:
- DO NOT create a public GitHub issue
- Email security@count.ly with details
- Include steps to reproduce
- Allow time for a fix before disclosure
See SECURITY.md in the repository root for more information.