Scala Slick Non-Literal SQL Injection Vulnerability

Critical Risk SQL Injection
ScalaSlickSQL InjectionDynamic QueriesVariable InterpolationDatabase Security

What it is

Application uses Slick with non-literal SQL constructions and dynamic query building using user input, creating SQL injection vulnerabilities through variable interpolation.

import slick.jdbc.H2Profile.api._
import scala.concurrent.Future

class DynamicQueryRepository(db: Database) {
  
  // Vulnerable: Variable interpolation in SQL
  def getUsersByField(fieldName: String, fieldValue: String): Future[Vector[String]] = {
    val query = sql"SELECT * FROM users WHERE $fieldName = '$fieldValue'".as[String]
    db.run(query)
  }
  
  // Vulnerable: Dynamic table name
  def getFromTable(tableName: String, condition: String): Future[Vector[String]] = {
    val query = sql"SELECT * FROM $tableName WHERE $condition".as[String]
    db.run(query)
  }
  
  // Vulnerable: Building ORDER BY clause
  def getUsersOrdered(sortField: String, sortOrder: String): Future[Vector[String]] = {
    val orderClause = s"$sortField $sortOrder"
    val query = sql"SELECT * FROM users ORDER BY #$orderClause".as[String]
    db.run(query)
  }
  
  // Vulnerable: Complex dynamic query
  def complexSearch(filters: Map[String, String], joins: List[String]): Future[Vector[String]] = {
    val joinClause = joins.mkString(" ")
    val whereClause = filters.map { case (field, value) =>
      s"$field = '$value'"
    }.mkString(" AND ")
    
    val query = sql"SELECT u.* FROM users u #$joinClause WHERE #$whereClause".as[String]
    db.run(query)
  }
  
  // Vulnerable: User-controlled LIMIT and OFFSET
  def getPaginatedData(limit: String, offset: String): Future[Vector[String]] = {
    val query = sql"SELECT * FROM users LIMIT $limit OFFSET $offset".as[String]
    db.run(query)
  }
}
import slick.jdbc.H2Profile.api._
import scala.concurrent.Future
import scala.util.{Try, Success, Failure}

case class User(id: Int, name: String, email: String, status: String, createdAt: java.sql.Timestamp)

class Users(tag: Tag) extends Table[User](tag, "users") {
  def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
  def name = column[String]("name")
  def email = column[String]("email")
  def status = column[String]("status")
  def createdAt = column[java.sql.Timestamp]("created_at")
  
  def * = (id, name, email, status, createdAt) <> (User.tupled, User.unapply)
}

class SecureQueryRepository(db: Database) {
  val users = TableQuery[Users]
  
  // Safe field mapping
  private val allowedSearchFields = Map(
    "name" -> "name",
    "email" -> "email", 
    "status" -> "status"
  )
  
  private val allowedSortFields = Map(
    "name" -> "name",
    "email" -> "email",
    "created" -> "created_at",
    "id" -> "id"
  )
  
  // Input validation helpers
  def validateFieldName(field: String): Either[String, String] = {
    allowedSearchFields.get(field.toLowerCase) match {
      case Some(validField) => Right(validField)
      case None => Left(s"Field '$field' is not allowed")
    }
  }
  
  def validateSortField(field: String): Either[String, String] = {
    allowedSortFields.get(field.toLowerCase) match {
      case Some(validField) => Right(validField)
      case None => Left(s"Sort field '$field' is not allowed")
    }
  }
  
  def validateSortOrder(order: String): Either[String, String] = {
    order.toLowerCase match {
      case "asc" => Right("asc")
      case "desc" => Right("desc")
      case _ => Left("Sort order must be 'asc' or 'desc'")
    }
  }
  
  def validatePaginationParam(param: String, max: Int): Either[String, Int] = {
    Try(param.toInt) match {
      case Success(value) if value >= 0 && value <= max => Right(value)
      case Success(_) => Left(s"Parameter must be between 0 and $max")
      case Failure(_) => Left("Parameter must be a valid integer")
    }
  }
  
  // Secure: Use type-safe queries instead of dynamic SQL
  def getUsersByField(fieldName: String, fieldValue: String): Future[Either[String, Seq[User]]] = {
    validateFieldName(fieldName) match {
      case Right(validField) =>
        if (fieldValue.length > 100) {
          Future.successful(Left("Field value too long"))
        } else {
          val query = validField match {
            case "name" => users.filter(_.name === fieldValue)
            case "email" => users.filter(_.email === fieldValue)
            case "status" => users.filter(_.status === fieldValue)
          }
          
          db.run(query.result).map(Right(_))
        }
        
      case Left(error) =>
        Future.successful(Left(error))
    }
  }
  
  // Secure: No dynamic table names - use predefined queries
  def getFromTable(tableType: String, userId: Int): Future[Either[String, Seq[User]]] = {
    tableType.toLowerCase match {
      case "users" =>
        val query = users.filter(_.id === userId)
        db.run(query.result).map(Right(_))
        
      case "active_users" =>
        val query = users.filter(u => u.id === userId && u.status === "active")
        db.run(query.result).map(Right(_))
        
      case _ =>
        Future.successful(Left("Invalid table type"))
    }
  }
  
  // Secure: Safe ordering with validation
  def getUsersOrdered(sortField: String, sortOrder: String): Future[Either[String, Seq[User]]] = {
    for {
      validField <- Future.fromTry(validateSortField(sortField).toTry)
      validOrder <- Future.fromTry(validateSortOrder(sortOrder).toTry)
    } yield {
      val baseQuery = users
      
      val orderedQuery = (validField, validOrder) match {
        case ("name", "asc") => baseQuery.sortBy(_.name.asc)
        case ("name", "desc") => baseQuery.sortBy(_.name.desc)
        case ("email", "asc") => baseQuery.sortBy(_.email.asc)
        case ("email", "desc") => baseQuery.sortBy(_.email.desc)
        case ("created_at", "asc") => baseQuery.sortBy(_.createdAt.asc)
        case ("created_at", "desc") => baseQuery.sortBy(_.createdAt.desc)
        case ("id", "asc") => baseQuery.sortBy(_.id.asc)
        case ("id", "desc") => baseQuery.sortBy(_.id.desc)
      }
      
      db.run(orderedQuery.result).map(Right(_))
    }
  }.flatten.recover {
    case ex: NoSuchElementException => Left("Validation failed")
    case _ => Left("Query failed")
  }
  
  // Secure: Type-safe complex search
  def complexSearch(
    nameOpt: Option[String],
    emailOpt: Option[String],
    statusOpt: Option[String]
  ): Future[Either[String, Seq[User]]] = {
    
    var query = users
    
    // Validate and apply filters
    nameOpt.foreach { name =>
      if (name.length <= 50) {
        query = query.filter(_.name like s"%$name%")
      } else {
        return Future.successful(Left("Name filter too long"))
      }
    }
    
    emailOpt.foreach { email =>
      if (email.length <= 100 && email.contains("@")) {
        query = query.filter(_.email like s"%$email%")
      } else {
        return Future.successful(Left("Invalid email filter"))
      }
    }
    
    statusOpt.foreach { status =>
      val allowedStatuses = Set("active", "inactive", "pending", "suspended")
      if (allowedStatuses.contains(status)) {
        query = query.filter(_.status === status)
      } else {
        return Future.successful(Left("Invalid status filter"))
      }
    }
    
    db.run(query.result).map(Right(_))
  }
  
  // Secure: Safe pagination
  def getPaginatedData(limitStr: String, offsetStr: String): Future[Either[String, Seq[User]]] = {
    for {
      limit <- Future.fromTry(validatePaginationParam(limitStr, 1000).toTry)
      offset <- Future.fromTry(validatePaginationParam(offsetStr, 100000).toTry)
    } yield {
      val query = users.drop(offset).take(limit)
      db.run(query.result).map(Right(_))
    }
  }.flatten.recover {
    case ex: NoSuchElementException => Left("Invalid pagination parameters")
    case _ => Left("Pagination query failed")
  }
  
  // Alternative: Safe search with predefined queries
  def predefinedSearch(searchType: String, searchValue: String): Future[Either[String, Seq[User]]] = {
    if (searchValue.length > 100) {
      return Future.successful(Left("Search value too long"))
    }
    
    val query = searchType.toLowerCase match {
      case "name_exact" => users.filter(_.name === searchValue)
      case "name_partial" => users.filter(_.name like s"%$searchValue%")
      case "email_domain" => users.filter(_.email like s"%@$searchValue")
      case "status" => 
        val allowedStatuses = Set("active", "inactive", "pending", "suspended")
        if (allowedStatuses.contains(searchValue)) {
          users.filter(_.status === searchValue)
        } else {
          return Future.successful(Left("Invalid status value"))
        }
      case _ => return Future.successful(Left("Invalid search type"))
    }
    
    db.run(query.result).map(Right(_))
  }
  
  // Secure: Use parameters for any necessary raw SQL
  def safeRawQuery(userId: Int): Future[Either[String, Vector[String]]] = {
    if (userId <= 0) {
      Future.successful(Left("Invalid user ID"))
    } else {
      // Safe: Use parameter binding
      val query = sql"SELECT name FROM users WHERE id = $userId AND status = 'active'".as[String]
      db.run(query).map(Right(_))
    }
  }
}

💡 Why This Fix Works

See fix suggestions for detailed explanation.

Why it happens

Slick code builds SQL dynamically: val sqlString = s"SELECT * FROM users WHERE email = '$email'"; sql"#$sqlString".as[User]. # passes raw SQL. String variable from user input. sql interpolator with # doesn't parameterize. SQL injection through sqlString.

Root causes

Using sql Interpolator with Non-Literal Strings

Slick code builds SQL dynamically: val sqlString = s"SELECT * FROM users WHERE email = '$email'"; sql"#$sqlString".as[User]. # passes raw SQL. String variable from user input. sql interpolator with # doesn't parameterize. SQL injection through sqlString.

Concatenating User Input Before sql Interpolator

Pre-built SQL: val query = "SELECT * FROM " + tableName + " WHERE id = " + userId; sql"#$query".as[Int]. tableName or userId from user. String concatenation before Slick. # operator treats as raw SQL. Circumvents parameterization safety.

Using # Operator with Variables Instead of $

Wrong interpolation operator: sql"SELECT * FROM users WHERE name = #$userName".as[User]. # for SQL fragments, $ for values. #$ treats userName as raw SQL. Should use $ alone. Operator confusion creates injection vulnerability.

Building WHERE Clauses Dynamically with String Operations

Dynamic conditions: val condition = filters.map(f => s"${f.column} = '${f.value}'").mkString(" AND "); sql"SELECT * FROM data WHERE #$condition".as[Data]. filters from user. Map creates SQL strings. # injection. Dynamic query building needs proper parameterization.

Using sql Interpolator with Computed SQL from External Sources

External SQL: val sqlFromConfig = config.getString("custom-query"); sql"#$sqlFromConfig".as[Result]. Configuration or database contains SQL. Not literal string in code. # treats as SQL. If config compromised or user-influenced, SQL injection possible.

Fixes

1

Always Use $ for Values, Never #$ with User Input

Correct parameterization: sql"SELECT * FROM users WHERE email = $email".as[User]. $ creates bind variable. Automatic parameterization. Type-safe values. Never use #$ with user input. # only for literal SQL fragments in code.

2

Use Slick Query API for Complex Dynamic Queries

Type-safe dynamic queries: var query = Users.filter(_.active === true); if (emailFilter.isDefined) query = query.filter(_.email === emailFilter.get); query.result. Compositional query building. Each filter parameterized. Compiler checked. No string building.

3

Use ActionList or DBIO.sequence for Multiple Parameterized Queries

Multiple queries safely: val queries = userIds.map(id => sql"SELECT * FROM users WHERE id = $id".as[User]); DBIO.sequence(queries). Each query parameterized with $. Batch operations type-safe. No string concatenation. Slick handles batching.

4

Validate Identifiers Against Allowlist for Dynamic Schemas

Safe dynamic identifiers: val allowedTables = Set("users", "posts", "comments"); require(allowedTables.contains(tableName)); sql"SELECT * FROM #$tableName WHERE id = $userId".as[Int]. Allowlist validation before #. Can't parameterize identifiers. Explicit validation required.

5

Use Slick's DynamicFilterDSL for User-Defined Filters

Safe filtering: import slick.collection.heterogeneous._; filters.foldLeft(query) { case (q, filter) => q.filter(row => filter.column.asColumnOf[String] === filter.value) }. DSL for dynamic filters. Type-safe composition. Parameterization maintained. Alternative to string building.

6

Scan for Unsafe sql Interpolator Usage

Find #$ patterns: grep -r '#\$' --include="*.scala". Review each usage. Ensure no user input with #. Replace with $ or query API. Static analysis for SQL injection. Code review checking interpolator usage. Automated scanning in CI/CD.

Detect This Vulnerability in Your Code

Sourcery automatically identifies scala slick non-literal sql injection vulnerability and many other security issues in your codebase.