Skip to content

Issue: TailwindSQL Parser v1 – Security, Robustness and Maintainability Issues #76

@andydefer

Description

@andydefer

Summary

The current TailwindSQL parser (v1) works for basic use cases but contains critical design, security, and robustness flaws that make it unsafe for production usage and hard to maintain or extend.

This issue documents all identified problems and explains why the current implementation must be refactored or replaced.

1. No Input Validation (Critical)

  • Table names, column names, and WHERE fields are not validated

  • Any string is accepted as-is

  • SQL reserved keywords are allowed (select, drop, union, etc.)

Impact

  • Invalid SQL generation

  • Undefined behavior at runtime

  • Attack surface for SQL injection -> db-users-drop-table-users

2 SQL Injection Vulnerability (Critical)

WHERE values are inserted directly into the query config :

  • No sanitization

  • No escaping

  • No parameterized queries

Impact

Remote SQL injection possible if class names come from user input (DOM, CMS, WYSIWYG, etc.)

Example attack vector:

db-users-where-name-"; DROP TABLE users; --

This input passes parsing without any error.

3. No Error Reporting or Diagnostics

Parser silently returns null

No distinction between:

  • Invalid syntax

  • Missing parts

  • Logical errors

  • Impossible to debug or log meaningful errors

Impact

  • Developers cannot understand why parsing fails

  • Production incidents are hard to investigate

4. Weak State Machine Logic

  • Parser state transitions are implicit and fragile

  • No protection against invalid sequences

Examples:

  • where without field/value

  • limit without number

  • orderby without direction

Impact

  • Invalid configurations are accepted

  • Partial queries may be generated unknowingly

5. Unlimited and Unsafe LIMIT Handling

limit accepts:

  • Negative numbers

  • Extremely large values

  • Non-sensical values (limit-999999999)

Impact

  • Performance degradation

  • Potential denial-of-service (DoS) vectors

  1. Column Handling Issues
  • No default column (*) when none is specified

  • Duplicate columns are allowed

  • Columns are not validated as identifiers

Impact

  • Invalid SQL

  • Redundant or broken SELECT clauses

7. Mixing Responsibilities (Design Flaw)

The parser:

  • Parses syntax

  • Builds semantic meaning

  • Assumes SQL correctness

  • All in one function.

Impact

  • Hard to test

  • Hard to extend (JOIN, GROUP BY, HAVING)

  • Violates Single Responsibility Principle

  1. No Defensive Programming
  • Assumes well-formed input

  • No boundary checks

  • No fallback mechanisms

Impact

  • One malformed className can break the parsing logic

  • Unexpected runtime behavior

  1. Not Production-Ready by Design
  • No security layer

  • No validation layer

  • No extensibility hooks

  • No safe SQL builder

Impact

Version 1 should never be exposed to untrusted input

  • High risk in public-facing applications

✅ Recommendation

Replace v1 with a hardened parser that includes:

  1. Identifier validation

  2. State-machine–based parsing

  3. Structured error reporting

  4. Parameterized SQL generation

(See proposed v2 implementation.)

/**  
 * TailwindSQL Parser - Safe and Robust Implementation  
 *   
 * Parses Tailwind-style class names into SQL query configurations with safety measures.  
 *   
 * @version 2.0.0  
 */  
  
// ============================================================================  
// INTERFACES ET TYPES  
// ============================================================================  
  
export interface JoinConfig {  
  table: string;  
  parentColumn: string;  
  childColumn: string;  
  columns: string[];  
  type: 'INNER' | 'LEFT' | 'RIGHT';  
}  
  
export interface QueryConfig {  
  table: string;  
  columns: string[];  
  where: Record<string, string>;  
  limit?: number;  
  orderBy?: {  
    field: string;  
    direction: 'asc' | 'desc';  
  };  
  joins?: JoinConfig[];  
}  
  
type ParserState =   
  | 'initial'   
  | 'column'   
  | 'where_field'   
  | 'where_value'   
  | 'limit'   
  | 'orderby_field'   
  | 'orderby_dir';  
  
type ValidationResult = {  
  isValid: boolean;  
  error?: string;  
};  
  
// ============================================================================  
// CONSTANTES ET CONFIGURATION  
// ============================================================================  
  
const CONFIG = {  
  PREFIX: 'db-',  
  KEYWORDS: new Set(['where', 'limit', 'orderby']),  
  MAX_LIMIT: 1000,  
  MIN_LIMIT: 1,  
  ALLOWED_ORDER_DIRECTIONS: new Set(['asc', 'desc']),  
  SQL_KEYWORDS: new Set([  
    'select', 'insert', 'update', 'delete', 'drop', 'truncate', 'alter',  
    'union', 'join', 'grant', 'revoke', 'create', 'exec', 'execute'  
  ])  
} as const;  
  
const IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;  
  
// ============================================================================  
// CLASSES D'ERREURS  
// ============================================================================  
  
class ParseError extends Error {  
  constructor(message: string, public readonly className?: string) {  
    super(message);  
    this.name = 'ParseError';  
  }  
}  
  
class ValidationError extends Error {  
  constructor(message: string, public readonly field?: string) {  
    super(message);  
    this.name = 'ValidationError';  
  }  
}  
  
// ============================================================================  
// VALIDATION UTILITIES  
// ============================================================================  
  
class Validator {  
  /**  
   * Validate SQL identifier (table, column names)  
   */  
  static validateIdentifier(identifier: string): ValidationResult {  
    if (!identifier) {  
      return { isValid: false, error: 'Identifier cannot be empty' };  
    }  
  
    if (!IDENTIFIER_REGEX.test(identifier)) {  
      return {   
        isValid: false,   
        error: `Invalid identifier format: ${identifier}. Use alphanumeric and underscores only.`  
      };  
    }  
  
    if (CONFIG.SQL_KEYWORDS.has(identifier.toLowerCase())) {  
      return {  
        isValid: false,  
        error: `Identifier "${identifier}" is a reserved SQL keyword`  
      };  
    }  
  
    return { isValid: true };  
  }  
  
  /**  
   * Validate numeric limit  
   */  
  static validateLimit(limit: number): ValidationResult {  
    if (isNaN(limit) || !Number.isInteger(limit)) {  
      return { isValid: false, error: 'Limit must be an integer' };  
    }  
  
    if (limit < CONFIG.MIN_LIMIT) {  
      return { isValid: false, error: `Limit must be at least ${CONFIG.MIN_LIMIT}` };  
    }  
  
    if (limit > CONFIG.MAX_LIMIT) {  
      return { isValid: false, error: `Limit cannot exceed ${CONFIG.MAX_LIMIT}` };  
    }  
  
    return { isValid: true };  
  }  
  
  /**  
   * Validate WHERE clause field/value  
   */  
  static validateWhereClause(field: string, value: string): ValidationResult {  
    const fieldValidation = this.validateIdentifier(field);  
    if (!fieldValidation.isValid) {  
      return fieldValidation;  
    }  
  
    if (!value || value.trim() === '') {  
      return { isValid: false, error: `WHERE value for "${field}" cannot be empty` };  
    }  
  
    // Basic SQL injection prevention  
    const dangerousPatterns = [  
      /['"\\]/g,  
      /;/g,  
      /--/g,  
      /\/\*/g,  
      /\*\//g  
    ];  
  
    for (const pattern of dangerousPatterns) {  
      if (pattern.test(value)) {  
        return {   
          isValid: false,   
          error: `Potentially dangerous value detected in WHERE clause for "${field}"`  
        };  
      }  
    }  
  
    return { isValid: true };  
  }  
  
  /**  
   * Validate complete QueryConfig  
   */  
  static validateQueryConfig(config: QueryConfig): ValidationResult {  
    // Validate table  
    const tableValidation = this.validateIdentifier(config.table);  
    if (!tableValidation.isValid) {  
      return tableValidation;  
    }  
  
    // Validate columns  
    for (const column of config.columns) {  
      if (column !== '*') {  
        const columnValidation = this.validateIdentifier(column);  
        if (!columnValidation.isValid) {  
          return columnValidation;  
        }  
      }  
    }  
  
    // Validate WHERE clauses  
    for (const [field, value] of Object.entries(config.where)) {  
      const whereValidation = this.validateWhereClause(field, value);  
      if (!whereValidation.isValid) {  
        return whereValidation;  
      }  
    }  
  
    // Validate limit  
    if (config.limit !== undefined) {  
      const limitValidation = this.validateLimit(config.limit);  
      if (!limitValidation.isValid) {  
        return limitValidation;  
      }  
    }  
  
    // Validate orderBy  
    if (config.orderBy) {  
      const orderFieldValidation = this.validateIdentifier(config.orderBy.field);  
      if (!orderFieldValidation.isValid) {  
        return orderFieldValidation;  
      }  
  
      if (!CONFIG.ALLOWED_ORDER_DIRECTIONS.has(config.orderBy.direction)) {  
        return {  
          isValid: false,  
          error: `Invalid order direction: ${config.orderBy.direction}`  
        };  
      }  
    }  
  
    return { isValid: true };  
  }  
}  
  
// ============================================================================  
// PARSER PRINCIPAL  
// ============================================================================  
  
class TailwindSQLParser {  
  private config: QueryConfig;  
  private state: ParserState;  
  private currentWhereField: string;  
  private parts: string[];  
  private currentIndex: number;  
  
  constructor() {  
    this.reset();  
  }  
  
  /**  
   * Reset parser state  
   */  
  private reset(): void {  
    this.config = {  
      table: '',  
      columns: [],  
      where: {}  
    };  
    this.state = 'initial';  
    this.currentWhereField = '';  
    this.parts = [];  
    this.currentIndex = 0;  
  }  
  
  /**  
   * Main parsing method  
   */  
  parse(className: string): QueryConfig {  
    this.reset();  
      
    try {  
      this.validateAndPrepareInput(className);  
      this.parseTable();  
      this.parseRemainingParts();  
      this.postProcessConfig();  
        
      const validationResult = Validator.validateQueryConfig(this.config);  
      if (!validationResult.isValid) {  
        throw new ValidationError(validationResult.error!);  
      }  
  
      return { ...this.config };  
    } catch (error) {  
      if (error instanceof ParseError || error instanceof ValidationError) {  
        throw error;  
      }  
      throw new ParseError(  
        `Failed to parse className: ${error instanceof Error ? error.message : 'Unknown error'}`,  
        className  
      );  
    }  
  }  
  
  /**  
   * Validate input and prepare for parsing  
   */  
  private validateAndPrepareInput(className: string): void {  
    if (!className || typeof className !== 'string') {  
      throw new ParseError('ClassName must be a non-empty string');  
    }  
  
    if (!className.startsWith(CONFIG.PREFIX)) {  
      throw new ParseError(`ClassName must start with "${CONFIG.PREFIX}"`, className);  
    }  
  
    const withoutPrefix = className.slice(CONFIG.PREFIX.length);  
    if (!withoutPrefix) {  
      throw new ParseError('No content after prefix', className);  
    }  
  
    this.parts = withoutPrefix.split('-');  
    this.currentIndex = 0;  
  }  
  
  /**  
   * Parse table name (first non-keyword part)  
   */  
  private parseTable(): void {  
    if (this.currentIndex >= this.parts.length) {  
      throw new ParseError('No table name specified');  
    }  
  
    const tableName = this.parts[this.currentIndex];  
      
    // Check if it's a keyword (shouldn't be)  
    if (CONFIG.KEYWORDS.has(tableName)) {  
      throw new ParseError(`"${tableName}" is a reserved keyword and cannot be used as table name`);  
    }  
  
    this.config.table = tableName;  
    this.currentIndex++;  
    this.state = 'column';  
  }  
  
  /**  
   * Get next part safely  
   */  
  private getNextPart(): string | null {  
    if (this.currentIndex >= this.parts.length) {  
      return null;  
    }  
    return this.parts[this.currentIndex++];  
  }  
  
  /**  
   * Parse all remaining parts  
   */  
  private parseRemainingParts(): void {  
    while (this.currentIndex < this.parts.length) {  
      const part = this.parts[this.currentIndex];  
        
      if (this.handleKeyword(part)) {  
        this.currentIndex++;  
        continue;  
      }  
  
      this.handlePartByState(part);  
      this.currentIndex++;  
    }  
  }  
  
  /**  
   * Handle keywords that change state  
   */  
  private handleKeyword(part: string): boolean {  
    switch (part) {  
      case 'where':  
        this.state = 'where_field';  
        return true;  
      case 'limit':  
        this.state = 'limit';  
        return true;  
      case 'orderby':  
        this.state = 'orderby_field';  
        return true;  
      default:  
        return false;  
    }  
  }  
  
  /**  
   * Handle part based on current state  
   */  
  private handlePartByState(part: string): void {  
    switch (this.state) {  
      case 'column':  
        this.handleColumnPart(part);  
        break;  
      case 'where_field':  
        this.handleWhereFieldPart(part);  
        break;  
      case 'where_value':  
        this.handleWhereValuePart(part);  
        break;  
      case 'limit':  
        this.handleLimitPart(part);  
        break;  
      case 'orderby_field':  
        this.handleOrderByFieldPart(part);  
        break;  
      case 'orderby_dir':  
        this.handleOrderByDirectionPart(part);  
        break;  
    }  
  }  
  
  private handleColumnPart(part: string): void {  
    if (!CONFIG.KEYWORDS.has(part)) {  
      this.config.columns.push(part);  
    }  
  }  
  
  private handleWhereFieldPart(part: string): void {  
    this.currentWhereField = part;  
    this.state = 'where_value';  
  }  
  
  private handleWhereValuePart(part: string): void {  
    this.config.where[this.currentWhereField] = part;  
    this.state = 'where_field';  
  }  
  
  private handleLimitPart(part: string): void {  
    const limit = parseInt(part, 10);  
    if (!isNaN(limit)) {  
      this.config.limit = Math.min(Math.max(limit, CONFIG.MIN_LIMIT), CONFIG.MAX_LIMIT);  
    }  
    this.state = 'column';  
  }  
  
  private handleOrderByFieldPart(part: string): void {  
    this.config.orderBy = {  
      field: part,  
      direction: 'asc'  
    };  
    this.state = 'orderby_dir';  
  }  
  
  private handleOrderByDirectionPart(part: string): void {  
    if (CONFIG.ALLOWED_ORDER_DIRECTIONS.has(part as 'asc' | 'desc')) {  
      this.config.orderBy!.direction = part as 'asc' | 'desc';  
    }  
    this.state = 'column';  
  }  
  
  /**  
   * Post-process configuration  
   */  
  private postProcessConfig(): void {  
    // If no columns specified, use wildcard  
    if (this.config.columns.length === 0) {  
      this.config.columns = ['*'];  
    }  
  
    // Remove duplicates from columns  
    this.config.columns = [...new Set(this.config.columns)];  
  }  
}  
  
// ============================================================================  
// FONCTIONS EXPORTÉES (MANTIENT LA SIGNATURE ORIGINALE)  
// ============================================================================  
  
const parser = new TailwindSQLParser();  
  
/**  
 * Parse a single className into QueryConfig  
 *   
 * @throws {ParseError | ValidationError} When parsing or validation fails  
 */  
export function parseClassName(className: string): QueryConfig | null {  
  try {  
    return parser.parse(className);  
  } catch (error) {  
    // Pour maintenir la compatibilité avec l'ancienne signature qui retourne null,  
    // on catch les erreurs et retourne null. En production, vous pourriez vouloir  
    // logger l'erreur ou utiliser une autre stratégie.  
      
    console.warn(`Failed to parse className "${className}":`, error);  
    return null;  
  }  
}  
  
/**  
 * Parse multiple class names and return the first valid one  
 *   
 * @throws {ParseError | ValidationError} When all parsing attempts fail  
 */  
export function parseClassNames(classNames: string): QueryConfig | null {  
  if (!classNames || typeof classNames !== 'string') {  
    return null;  
  }  
  
  const classes = classNames.split(/\s+/).filter(c => c.trim());  
  
  for (const className of classes) {  
    try {  
      const config = parser.parse(className);  
      if (config) {  
        return config;  
      }  
    } catch (error) {  
      // Continue to next className  
      continue;  
    }  
  }  
  
  return null;  
}  
  
/**  
 * Safe parse with error handling (recommandé pour la production)  
 */  
export function safeParseClassName(className: string): {  
  config: QueryConfig | null;  
  error: Error | null;  
} {  
  try {  
    const config = parser.parse(className);  
    return { config, error: null };  
  } catch (error) {  
    return {   
      config: null,   
      error: error instanceof Error ? error : new Error('Unknown parsing error')  
    };  
  }  
}  
  
// ============================================================================  
// UTILITIES ADDITIONNELLES  
// ============================================================================  
  
/**  
 * Convert QueryConfig to parameterized SQL (safe)  
 */  
export function buildSafeSQL(config: QueryConfig): {   
  sql: string;   
  params: any[];  
  warnings: string[];  
} {  
  const warnings: string[] = [];  
  const params: any[] = [];  
  const escapedTable = `"${config.table.replace(/"/g, '""')}"`;  
    
  // Build columns  
  const columns = config.columns  
    .map(col => col === '*' ? '*' : `"${col.replace(/"/g, '""')}"`)  
    .join(', ');  
  
  let sql = `SELECT ${columns} FROM ${escapedTable}`;  
  
  // Build WHERE clause  
  const whereConditions = Object.entries(config.where);  
  if (whereConditions.length > 0) {  
    const conditions = whereConditions.map(([field, value], index) => {  
      params.push(value);  
      const escapedField = `"${field.replace(/"/g, '""')}"`;  
      return `${escapedField} = $${index + 1}`;  
    });  
    sql += ` WHERE ${conditions.join(' AND ')}`;  
  }  
  
  // Build ORDER BY  
  if (config.orderBy) {  
    const escapedField = `"${config.orderBy.field.replace(/"/g, '""')}"`;  
    sql += ` ORDER BY ${escapedField} ${config.orderBy.direction.toUpperCase()}`;  
  }  
  
  // Build LIMIT  
  if (config.limit) {  
    sql += ` LIMIT ${config.limit}`;  
  }  
  
  return { sql, params, warnings };  
}  
  
/**  
 * Test if a string is a valid TailwindSQL className  
 */  
export function isValidTailwindSQL(className: string): boolean {  
  if (!className.startsWith(CONFIG.PREFIX)) {  
    return false;  
  }  
  
  try {  
    const config = parser.parse(className);  
    return config !== null;  
  } catch {  
    return false;  
  }  
}  
  
// ============================================================================  
// EXPORTS POUR LES TESTS  
// ============================================================================  
  
export {  
  ParseError,  
  ValidationError,  
  Validator,  
  CONFIG as ParserConfig  
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions