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
30 changes: 30 additions & 0 deletions src/controllers/v1/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,36 @@ module.exports = class Admin {
}
}

/**
* Execute raw SELECT query with pagination
* @method
* @name executeRawQuery
* @param {Object} req - Request object containing query in body and pagination params
* @param {String} req.body.query - Raw SQL SELECT query
* @param {Number} req.pageNo - Page number
* @param {Number} req.pageSize - Page size limit
* @returns {JSON} - Paginated query results
*/
async executeRawQuery(req) {
try {
if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, common.ADMIN_ROLE)) {
throw responses.failureResponse({
message: 'USER_IS_NOT_A_ADMIN',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}

const { query } = req.body

const pageNo = req.pageNo
const pageSize = req.pageSize

return await adminService.executeRawQuery(query, req.decodedToken.id, pageNo, pageSize)
} catch (error) {
return error
}
}
Comment thread
nevil-mathew marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
nevil-mathew marked this conversation as resolved.
async triggerViewRebuild(req) {
try {
if (!req.decodedToken.roles.some((role) => role.title === common.ADMIN_ROLE)) {
Expand Down
7 changes: 7 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@
"INVALID_REG_CODE_ERROR": "registration_codes {{errorMessage}}. Invalid Code(s) : {{errorValues}}",
"UNIQUE_CONSTRAINT_ERROR": "{{fields}} is Invalid.",
"USER_PROFILE_FETCHED_SUCCESSFULLY": "User profile fetched successfully!",
"QUERY_EXECUTED_SUCCESSFULLY": "Raw query executed successfully!",
"INVALID_QUERY_INPUT": "Invalid query input provided.",
"ONLY_SELECT_QUERIES_ALLOWED": "Only SELECT queries are allowed.",
"QUERY_EXECUTION_FAILED": "Failed to execute query.",
"QUERY_INVALID_OR_UNBALANCED_QUOTES": "Query contains unbalanced or invalid quotes.",
"QUERY_FORBIDDEN_INJECTION_PATTERNS": "Query contains forbidden injection patterns.",
"QUERY_FORBIDDEN_PATTERNS": "Query contains forbidden SQL operations.",
"ADD_ORG_HEADER": "Please provide all required organization headers: {{orgCodeHeader}}, and {{tenantCodeHeader}} for admin override.",
"INVALID_ORG_ID": "Organization ID must be a valid positive integer.",
"INVALID_ORG_OR_TENANT_CODE": "The provided organization or tenant code is invalid or does not match."
Expand Down
7 changes: 7 additions & 0 deletions src/middlewares/authenticator.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,14 @@ module.exports = async function (req, res, next) {
decodedToken.data.tenant_code = tenantCode
decodedToken.data.organization_id = org.id // Use the ID from the database
decodedToken.data.organization_code = orgCode
} else {
req.decodedToken = decodedToken.data
}
// Admin users intentionally bypass role and permission validation below.
// This early return ensures admins proceed without further checks.
// If admin access rules change, remove or adjust this bypass accordingly.

return next()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (roleValidation) {
Expand Down
71 changes: 71 additions & 0 deletions src/services/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const bcryptJs = require('bcryptjs')
const common = require('@constants/common')
const httpStatusCode = require('@generics/http-status')
const utils = require('@generics/utils')
const rawQueryUtils = require('@utils/rawQueryUtils')

// Database queries
const organizationQueries = require('@database/queries/organization')
Expand Down Expand Up @@ -820,6 +821,76 @@ module.exports = class AdminHelper {
throw error
}
}
/**
* Execute raw SELECT query with pagination
* @method
* @name executeRawQuery
* @param {String} query - Raw SQL SELECT query
* @param {String} adminUserId - ID of the admin executing the query
* @param {Number} pageNo - Page number
* @param {Number} pageSize - Page size limit
* @returns {JSON} - Paginated query results with count
*/
static async executeRawQuery(query, adminUserId, pageNo, pageSize) {
try {
// Basic input validation
if (!query || typeof query !== 'string') {
return responses.failureResponse({
message: 'INVALID_QUERY_INPUT',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}

// Trim and strip trailing semicolon(s)
let sanitizedQuery = query.trim().replace(/;+\s*$/, '')

// Security validation
rawQueryUtils.validateQuerySecurity(sanitizedQuery)

// Log the query for auditing
console.log(`Admin ${adminUserId} executed query: ${sanitizedQuery} (Page: ${pageNo}, Size: ${pageSize})`)

// Get pagination parameters
const { limit, offset } = rawQueryUtils.getPaginationParams(pageNo, pageSize)

// Execute the paginated query with bindings
const paginatedQuery = `${sanitizedQuery} LIMIT :limit OFFSET :offset`
const data = await sequelize.query(paginatedQuery, {
replacements: { limit, offset },
type: sequelize.QueryTypes.SELECT,
timeout: 30000, // Prevent long-running queries
})
// Get total count (use sanitizedQuery as subquery)
const countQuery = `SELECT COUNT(*) AS count FROM (${sanitizedQuery}) AS subquery`
const [countResult] = await sequelize.query(countQuery, {
type: sequelize.QueryTypes.SELECT,
timeout: 30000,
})
const count = Number(countResult.count) // Coerce to number

return responses.successResponse({
statusCode: httpStatusCode.ok,
message: 'QUERY_EXECUTED_SUCCESSFULLY',
result: {
data,
count,
},
})
Comment thread
nevil-mathew marked this conversation as resolved.
} catch (error) {
console.error('Error executing raw query:', error)
const isValidationError =
error.message &&
(error.message.startsWith('QUERY_') ||
error.message === 'INVALID_QUERY_INPUT' ||
error.message === 'ONLY_SELECT_QUERIES_ALLOWED')
return responses.failureResponse({
message: isValidationError ? error.message : 'QUERY_EXECUTION_FAILED',
statusCode: isValidationError ? httpStatusCode.bad_request : httpStatusCode.internal_server_error,
responseCode: isValidationError ? 'CLIENT_ERROR' : 'SERVER_ERROR',
})
}
}
Comment thread
nevil-mathew marked this conversation as resolved.

static async triggerViewRebuild(decodedToken) {
try {
Expand Down
125 changes: 125 additions & 0 deletions src/utils/rawQueryUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Utilities for validating and processing raw SQL SELECT queries
* @module rawQueryUtils
*/

/**
* Comprehensive security validation for SQL queries
* @param {string} query - The raw SQL query to validate
* @throws {Error} - If validation fails
*/
function validateQuerySecurity(query) {
const lowerQuery = query.toLowerCase()

// Check starts with SELECT or WITH
if (!/^\s*(with|select)\b/.test(lowerQuery)) {
throw new Error('ONLY_SELECT_QUERIES_ALLOWED')
}

// Check for unbalanced quotes
if (!hasValidQuotes(query)) {
throw new Error('QUERY_INVALID_OR_UNBALANCED_QUOTES')
}

// Check for critical injection patterns
if (hasCriticalInjectionPatterns(lowerQuery)) {
throw new Error('QUERY_FORBIDDEN_INJECTION_PATTERNS')
}

// Normalize and check for forbidden patterns
const normalizedQuery = normalizeQuery(query)
if (hasForbiddenPatterns(normalizedQuery)) {
throw new Error('QUERY_FORBIDDEN_PATTERNS')
}
}

/**
* Calculates pagination parameters with validation
* @param {number} pageNo - Page number
* @param {number} pageSize - Page size
* @returns {Object} - { limit, offset }
*/
function getPaginationParams(pageNo, pageSize) {
const validPageNo = Math.max(1, parseInt(pageNo) || 1)
const validPageSize = Math.min(Math.max(1, parseInt(pageSize) || 100), 100) // Cap at 100
const offset = (validPageNo - 1) * validPageSize

return { limit: validPageSize, offset }
}
Comment thread
nevil-mathew marked this conversation as resolved.

/**
* Checks for critical injection patterns
* @param {string} lowerQuery - Lowercased query
* @returns {boolean} - True if critical patterns are found
*/
function hasCriticalInjectionPatterns(lowerQuery) {
const criticalPatterns = [';', '--', '/*', '*/', '#', '\\']
return criticalPatterns.some((pattern) => lowerQuery.includes(pattern))
}

/**
* Removes comments and string literals for pattern analysis
* @param {string} query - The raw query
* @returns {string} - Normalized query
*/
function normalizeQuery(query) {
return query
.toLowerCase()
.replace(/\/\*[\s\S]*?\*\//g, ' ') // Remove /* */ comments
.replace(/--.*$/gm, ' ') // Remove -- comments
.replace(/#[^\n]*/g, ' ') // Remove # comments
.replace(/'(?:''|[^'])*'/g, ' ') // Remove single-quoted strings
.replace(/"(?:""|[^"])*"/g, ' ') // Remove double-quoted strings
.replace(/\s+/g, ' ') // Normalize whitespace
.trim()
}

/**
* Checks if normalized query contains forbidden operations
* @param {string} normalizedQuery - Normalized query
* @returns {boolean} - True if forbidden patterns are found
*/
function hasForbiddenPatterns(normalizedQuery) {
const forbiddenPatterns = /\b(drop|alter|truncate|insert|update|delete|union|concat|\|\|)\b/i
return forbiddenPatterns.test(normalizedQuery)
}

/**
* Validates balanced quotes with escaping
* @param {string} query - The raw query
* @returns {boolean} - True if quotes are balanced
*/
function hasValidQuotes(query) {
let inSingleQuote = false
let inDoubleQuote = false

for (let i = 0; i < query.length; i++) {
const char = query[i]
const nextChar = query[i + 1]

if (!inDoubleQuote && char === "'") {
if (inSingleQuote && nextChar === "'") {
i++ // Skip escaped ''
continue
}
inSingleQuote = !inSingleQuote
} else if (!inSingleQuote && char === '"') {
if (inDoubleQuote && nextChar === '"') {
i++ // Skip escaped ""
continue
}
inDoubleQuote = !inDoubleQuote
}
}

return !inSingleQuote && !inDoubleQuote
}

module.exports = {
validateQuerySecurity,
getPaginationParams,
hasCriticalInjectionPatterns,
normalizeQuery,
hasForbiddenPatterns,
hasValidQuotes,
}
3 changes: 3 additions & 0 deletions src/validators/v1/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,7 @@ module.exports = {
.matches(/^[a-z0-9_]+$/)
.withMessage('tenant-id must be lowercase alphanumeric with underscores.')
},
executeRawQuery: (req) => {
req.checkBody('query').trim().notEmpty().withMessage('query field is empty')
},
Comment thread
nevil-mathew marked this conversation as resolved.
}