GraphQL Security Considerations

GraphQL Security Considerations

GraphQL's flexibility introduces unique security challenges. Query depth attacks can exhaust server resources through deeply nested queries. Query complexity attacks combine multiple expensive operations. Introspection in production environments can expose entire API schemas to attackers. These risks require GraphQL-specific security measures beyond traditional REST API protections.

Query depth limiting prevents nested query attacks by rejecting queries exceeding specified depth. Query complexity analysis assigns costs to different operations and rejects queries exceeding complexity budgets. Field-level authorization ensures users can only access permitted data regardless of query structure. Persistent queries eliminate arbitrary query execution by requiring pre-approved query templates.

// Example: GraphQL security implementation
const depthLimit = require('graphql-depth-limit');
const costAnalysis = require('graphql-cost-analysis');
const { GraphQLError } = require('graphql');

class GraphQLSecurityMiddleware {
    constructor(config) {
        this.config = config;
        this.queryWhitelist = new Map();
        this.queryCache = new LRUCache({ max: 1000 });
    }
    
    getSecurityRules() {
        return [
            // Depth limiting
            depthLimit(this.config.maxDepth || 7),
            
            // Query cost analysis
            costAnalysis({
                maximumCost: this.config.maxCost || 1000,
                defaultCost: 1,
                scalarCost: 1,
                objectCost: 2,
                listFactor: 10,
                introspectionCost: 1000,
                enforceIntrospectionCost: true,
                
                createError: (max, actual) => {
                    return new GraphQLError(
                        `Query exceeded maximum cost of ${max}. Actual cost: ${actual}`
                    );
                },
                
                onComplete: (cost) => {
                    // Log expensive queries
                    if (cost > this.config.maxCost * 0.8) {
                        this.logExpensiveQuery(cost);
                    }
                }
            }),
            
            // Custom security rules
            this.createCustomSecurityRules()
        ];
    }
    
    createCustomSecurityRules() {
        return {
            // Disable introspection in production
            validationRules: ({ params, request }) => {
                if (this.config.env === 'production') {
                    return [
                        require('graphql-disable-introspection').disableIntrospection
                    ];
                }
                return [];
            },
            
            // Query whitelisting for high-security mode
            preExecute: async ({ params, request }) => {
                if (this.config.whitelistMode) {
                    const queryHash = this.hashQuery(params.query);
                    
                    if (!this.queryWhitelist.has(queryHash)) {
                        throw new GraphQLError('Query not whitelisted');
                    }
                }
                
                // Rate limiting per query type
                await this.enforceQueryRateLimit(params, request);
            },
            
            // Field-level authorization
            fieldResolver: (next) => async (source, args, context, info) => {
                // Check field-level permissions
                const authorized = await this.checkFieldAuthorization(
                    info.fieldName,
                    info.parentType.name,
                    context.user
                );
                
                if (!authorized) {
                    throw new GraphQLError(
                        `Unauthorized access to field ${info.parentType.name}.${info.fieldName}`
                    );
                }
                
                // Apply data masking if needed
                const result = await next(source, args, context, info);
                return this.applyDataMasking(result, info, context);
            }
        };
    }
    
    async checkFieldAuthorization(fieldName, typeName, user) {
        // Define field-level permissions
        const permissions = {
            'User.email': ['self', 'admin'],
            'User.ssn': ['self:verified', 'admin:verified'],
            'Order.total': ['self', 'admin', 'finance'],
            'CreditCard.number': ['payment_processor']
        };
        
        const requiredPerms = permissions[`${typeName}.${fieldName}`];
        if (!requiredPerms) return true; // No restrictions
        
        // Check user permissions
        return requiredPerms.some(perm => {
            if (perm.includes(':')) {
                const [role, requirement] = perm.split(':');
                return user.roles.includes(role) && 
                       this.meetsRequirement(user, requirement);
            }
            return user.roles.includes(perm);
        });
    }
    
    applyDataMasking(data, info, context) {
        // Mask sensitive data based on user permissions
        const maskingRules = {
            'CreditCard.number': (value, user) => {
                if (!user.roles.includes('payment_processor')) {
                    return value.replace(/\d(?=\d{4})/g, '*');
                }
                return value;
            },
            'User.ssn': (value, user) => {
                if (!user.roles.includes('admin')) {
                    return '***-**-' + value.slice(-4);
                }
                return value;
            }
        };
        
        const rule = maskingRules[`${info.parentType.name}.${info.fieldName}`];
        if (rule && data) {
            return rule(data, context.user);
        }
        
        return data;
    }
    
    // Prevent aliasing attacks
    validateQueryAliasing(query) {
        const ast = parse(query);
        const fieldCounts = new Map();
        
        visit(ast, {
            Field(node) {
                const fieldName = node.name.value;
                const count = fieldCounts.get(fieldName) || 0;
                fieldCounts.set(fieldName, count + 1);
                
                if (count + 1 > this.config.maxAliasCount) {
                    throw new GraphQLError(
                        `Excessive aliasing detected for field: ${fieldName}`
                    );
                }
            }
        });
    }
}