Skip to content
Merged
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
5 changes: 2 additions & 3 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const { getPublicIp } = require('./utils/network');

// Initialize Queue Workers
require('./queues/emailQueue');
require('./queues/authEmailQueue');

// Middleware (We apply admin options globally except where overridden via dynamic handling)
app.use(express.json());
Expand Down Expand Up @@ -78,9 +79,7 @@ const schemaRoute = require('./routes/schemas');
const releaseRoute = require('./routes/releases');

// ROUTES SETUP
app.use('/api/auth/login', cors(adminCorsOptions), authLimiter); // Strict limiter on login
app.use('/api/auth/register', cors(adminCorsOptions), authLimiter); // Strict limiter on register
app.use('/api/auth', cors(adminCorsOptions), dashboardLimiter, authRoute); // Developer Auth (general)
app.use('/api/auth', cors(adminCorsOptions), authRoute); // Developer Auth (general)
app.use('/api/projects', cors(adminCorsOptions), dashboardLimiter, projectRoute); // Project Mgmt
app.use('/api/userAuth', cors(adminCorsOptions), limiter, logger, userAuthRoute);
app.use('/api/releases', cors(adminCorsOptions), releaseRoute);
Expand Down
192 changes: 159 additions & 33 deletions backend/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ const { getCompiledModel } = require("../utils/injectModel")
const QueryEngine = require("../utils/queryEngine");
const { storageRegistry } = require("../utils/registry");
const { deleteProjectByApiKeyCache, setProjectById, getProjectById, deleteProjectById } = require("../services/redisCaching");
const { v4: uuidv4 } = require('uuid');
const { getPublicIp } = require("../utils/network");

const validateUsersSchema = (schema) => {
if (!Array.isArray(schema)) return false;
const hasEmail = schema.find(f => f.key === 'email' && f.type === 'String' && f.required);
const hasPassword = schema.find(f => f.key === 'password' && f.type === 'String' && f.required);
return !!(hasEmail && hasPassword);
};



const getBucket = (project) =>
Expand Down Expand Up @@ -95,20 +103,38 @@ module.exports.getAllProject = async (req, res) => {

module.exports.getSingleProject = async (req, res) => {
try {
let project;
project = await getProjectById(req.params.projectId);
let projectObj;
if (!project) {
project = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('-publishableKey -secretKey -jwtSecret');
let projectObj = await getProjectById(req.params.projectId);

if (!projectObj) {
const project = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('-publishableKey -secretKey -jwtSecret');
if (!project) return res.status(404).json({ error: "Project not found." });
projectObj = project.toObject();
await setProjectById(req.params.projectId, project);
await setProjectById(req.params.projectId, projectObj);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The setProjectById function is called with projectObj which is the result of project.toObject(). This is good as it ensures a plain JavaScript object is cached, preventing potential issues with caching Mongoose documents directly. This aligns with the PR description's mention of fixing a critical caching bug.

}

// Ownership Check (Even for Cache)
if (projectObj.owner.toString() !== req.user._id.toString()) {
return res.status(403).json({ error: "Access denied." });
}

projectObj = project;
// Just to be safe, we remove sensitive fields again even if from cache
delete projectObj.publishableKey;
delete projectObj.secretKey;
delete projectObj.jwtSecret;

// SANITIZE COLLECTION MODELS: Remove "password" from "users" collection schema
if (projectObj.collections && Array.isArray(projectObj.collections)) {
projectObj.collections = projectObj.collections.map(col => {
if (col.name === 'users' && col.model) {
return {
...col,
model: col.model.filter(m => m.key !== 'password')
};
}
return col;
});
}
Comment on lines +126 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

This block correctly sanitizes the users collection schema by removing the password field before sending the project object to the frontend. This is a crucial security measure to prevent accidental exposure of schema details for sensitive fields.


res.json(projectObj);
} catch (err) {
res.status(500).json({ error: err.message });
Expand Down Expand Up @@ -283,6 +309,7 @@ module.exports.deleteExternalStorageConfig = async (req, res) => {



// POST REQ FOR CREATE COLLECTION
module.exports.createCollection = async (req, res) => {
try {
const { projectId, collectionName, schema } = createCollectionSchema.parse(req.body);
Expand All @@ -295,14 +322,19 @@ module.exports.createCollection = async (req, res) => {

if (!project.jwtSecret) project.jwtSecret = uuidv4();

if (collectionName === 'users') {
if (!validateUsersSchema(schema)) {
return res.status(422).json({ error: "The 'users' collection must have required 'email' and 'password' string fields." });
}
}

project.collections.push({ name: collectionName, model: schema });
await project.save();

await deleteProjectById(projectId);
await setProjectById(projectId, project);
await deleteProjectByApiKeyCache(project.publishableKey);
await deleteProjectByApiKeyCache(project.secretKey);
// RESPONSE
const projectObj = project.toObject();
delete projectObj.publishableKey;
delete projectObj.secretKey;
Expand Down Expand Up @@ -371,7 +403,12 @@ module.exports.getData = async (req, res) => {

// const collectionsList = await mongoose.connection.db.listCollections({ name: finalCollectionName }).toArray();

const features = new QueryEngine(model.find(), req.query)
const query = model.find();
if (collectionName === 'users') {
query.select('-password');
}
Comment on lines +406 to +409

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

This is a good security practice to explicitly exclude the password field when querying the users collection. This prevents password hashes from being inadvertently exposed through general data retrieval endpoints.


const features = new QueryEngine(query, req.query)
.filter()
.sort()
.paginate();
Expand All @@ -391,6 +428,10 @@ module.exports.insertData = async (req, res) => {
const project = await Project.findOne({ _id: projectId, owner: req.user._id });
if (!project) return res.status(404).json({ error: "Project not found." });

if (collectionName === 'users') {
return res.status(400).json({ error: "Direct inserts into 'users' collection are not allowed. Please use the Auth signup or admin endpoints." });
}
Comment on lines +431 to +433

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Preventing direct inserts into the users collection through this general endpoint is a strong security measure. It forces the use of dedicated authentication or admin endpoints, which can enforce proper hashing, validation, and other security protocols.


const finalCollectionName = `${project._id}_${collectionName}`;
const incomingData = req.body;

Expand Down Expand Up @@ -419,7 +460,6 @@ module.exports.insertData = async (req, res) => {
project.databaseUsed = (project.databaseUsed || 0) + docSize;
}
await project.save();
await project.save();

res.json(result);
} catch (err) {
Expand Down Expand Up @@ -480,6 +520,14 @@ module.exports.editRow = async (req, res) => {
const connection = await getConnection(projectId);
const Model = getCompiledModel(connection, collectionConfig, projectId, project.resources.db.isExternal);

if (collectionName === 'users') {
delete req.body.password;
// Also ensure it's not and nested or sneaky
Object.keys(req.body).forEach(key => {
if (key.toLowerCase().includes('password')) delete req.body[key];
});
Comment on lines +523 to +528

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

This is a critical security improvement. Explicitly deleting password and any case-insensitive password related keys from the request body before an update prevents an attacker from attempting to overwrite or inject unhashed passwords into the users collection via the general editRow endpoint.

}

const docToEdit = await Model.findById(id);
if (!docToEdit) {
return res.status(404).json({ error: "Document not found." });
Expand All @@ -505,8 +553,12 @@ module.exports.editRow = async (req, res) => {
}

const updatedDoc = await docToEdit.save();
const responseData = updatedDoc.toObject();
if (collectionName === 'users') {
delete responseData.password;
}
Comment on lines +556 to +559

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Removing the password field from the responseData before sending it back to the client is a good practice. Even if the input was sanitized, ensuring the output is also clean adds another layer of protection against accidental data exposure.


res.json({ success: true, message: "Document edited successfully", data: updatedDoc });
res.json({ success: true, message: "Document edited successfully", data: responseData });

} catch (err) {
console.error("Edit Error:", err);
Expand Down Expand Up @@ -754,35 +806,41 @@ module.exports.deleteProject = async (req, res) => {
if (!project) {
return res.status(404).json({ error: "Project not found or access denied." });
}
for (const col of project.collections) {
const collectionName = `${project._id}_${col.name}`;

// DROP COLLECTIONS: Only for internal databases
if (!project.resources.db.isExternal) {
for (const col of project.collections) {
const collectionName = `${project._id}_${col.name}`;
try {
await mongoose.connection.db.dropCollection(collectionName);
} catch (e) { }
}

try {
await mongoose.connection.db.dropCollection(collectionName);
await mongoose.connection.db.dropCollection(`${project._id}_users`);
} catch (e) { }
}
Comment on lines +810 to 822

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The conditional logic to only drop collections for internal databases (!project.resources.db.isExternal) is a significant improvement for resource safety, especially when dealing with external database providers. This prevents accidental data loss on external systems.


try {
await mongoose.connection.db.dropCollection(`${project._id}_users`);
} catch (e) { }

// DELETE FILES
const supabase = await getStorage(project);
const bucket = getBucket(project);
// DELETE: Only for internal Infraa
if (!isExternalStorage(project)) {
const supabase = await getStorage(project);
const bucket = getBucket(project);

let hasMoreFiles = true;
let hasMoreFiles = true;

while (hasMoreFiles) {
const { data: files, error } = await supabase.storage
.from(bucket)
.list(projectId, { limit: 100 });
while (hasMoreFiles) {
const { data: files, error } = await supabase.storage
.from(bucket)
.list(projectId, { limit: 100 });

if (error) throw error;
if (error) throw error;

if (files && files.length > 0) {
const paths = files.map(f => `${projectId}/${f.name}`);
await supabase.storage.from(bucket).remove(paths);
} else {
hasMoreFiles = false;
if (files && files.length > 0) {
const paths = files.map(f => `${projectId}/${f.name}`);
await supabase.storage.from(bucket).remove(paths);
} else {
hasMoreFiles = false;
}
}
}
Comment on lines +825 to 845

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Similarly, applying the !isExternalStorage(project) condition to file deletion ensures that files are only removed from internal storage infrastructure. This is crucial for preventing data loss when external storage solutions are in use.


Expand All @@ -800,7 +858,8 @@ module.exports.deleteProject = async (req, res) => {
module.exports.analytics = async (req, res) => {
try {
const { projectId } = req.params;
const project = await Project.findOne({ _id: projectId });
const project = await Project.findOne({ _id: projectId, owner: req.user._id });
if (!project) return res.status(404).json({ error: "Project not found or access denied." });
const totalRequests = await Log.countDocuments({ projectId });
const logs = await Log.find({ projectId }).sort({ timestamp: -1 }).limit(50);

Expand Down Expand Up @@ -828,4 +887,71 @@ module.exports.analytics = async (req, res) => {
} catch (err) {
res.status(500).json({ error: err.message });
}
}

// FUNCTION - TOGGLE AUTH
module.exports.toggleAuth = async (req, res) => {
try {
const { projectId } = req.params;
const { enable } = req.body; // true or false

// Ensure user owns project
const project = await Project.findOne({ _id: projectId, owner: req.user._id });
if (!project) return res.status(404).json({ error: "Project not found" });

if (enable) {
let usersCol = project.collections.find(c => c.name === 'users');
if (!usersCol) {
usersCol = {
name: 'users',
model: [
{ key: 'email', type: 'String', required: true },
{ key: 'username', type: 'String', required: false },
{ key: 'password', type: 'String', required: true },
{ key: 'emailVerified', type: 'Boolean', required: false }
]
};
project.collections.push(usersCol);
} else {
if (!validateUsersSchema(usersCol.model)) {
return res.status(422).json({
error: "Invalid Users Schema",
message: "The 'users' collection must have required 'email' and 'password' string fields. Please fix the schema before enabling Auth."
});
}
}
}

project.isAuthEnabled = !!enable;
await project.save();

await deleteProjectById(projectId);
await deleteProjectByApiKeyCache(project.publishableKey);
await deleteProjectByApiKeyCache(project.secretKey);

const projectObj = project.toObject();
delete projectObj.publishableKey;
delete projectObj.secretKey;
delete projectObj.jwtSecret;

if (projectObj.collections && Array.isArray(projectObj.collections)) {
projectObj.collections = projectObj.collections.map(col => {
if (col.name === 'users' && col.model) {
return {
...col,
model: col.model.filter(m => m.key !== 'password')
};
}
return col;
});
}

res.json({
message: `Authentication ${project.isAuthEnabled ? 'enabled' : 'disabled'} successfully`,
isAuthEnabled: project.isAuthEnabled,
project: projectObj
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
res.status(500).json({ error: err.message });
}
}
Loading