Evitando SQL Injection com Golang

Wiliam V. Joaquim - Dec 27 '23 - - Dev Community

O que vamos abordar?

SQL injection é uma das técnicas mais utilizadas para realizar ataques no seu sistema, onde podemos executar sql malicioso em endpoints vulneráveis sendo possível manipular seu banco de dados, apesar de já existir muitas formas de mitigar esse ataque, ainda é possível deixar essa brecha caso o desenvolvedor não fique atento.

A maioria dos ORM's já inibem esse tipo de ataque, mas em Go é muito comum não utilizar ORM as chances dessa vulnerabilidade acontecer é maior caso não seja tratado corretamente.

Como o SQL Injection funciona

Geralmente o sql injection acontece em rotas que disponibilizam filtros que possibilitam passar parâmetros e esses parâmetros não são tratados corretamente, por exemplo:

GET http://localhost:8080/users?id=1 HTTP/1.1
Enter fullscreen mode Exit fullscreen mode

Esse endpoint busca um usuário pelo id, porém sem o tratamento correto pode ser feito uma injeção de sql.

GET http://localhost:8080/users?id='1'OR'1'='1' HTTP/1.1
Enter fullscreen mode Exit fullscreen mode

Adicionando o sql '1'OR'1'='1' que ignora qualquer condição, '1'='1 retorna um booleano verdadeiro, dessa forma a consulta sempre retorna todos os usuários do nosso banco de dados, essa vulnerabilidade é grave, podemos inclusive deletar toda a base de dados.

Criando um seed

Para ajudar no teste, vamos criar um seed (popular registros na tabela), para facilitar nossos testes, dentro da pasta database vamos criar um arquivo seed.go:

  func SeedUsers() error {
    // drop table users
    _, err := DBConnection.Exec(`DROP TABLE IF EXISTS users`)
    if err != nil {
      log.Fatal(err)
    }

    // create table users
    createTableQuery := `
      CREATE TABLE IF NOT EXISTS users (
        id SERIAL PRIMARY KEY,
        name VARCHAR(256) NOT NULL,
        email VARCHAR(256) NOT NULL UNIQUE,
        password VARCHAR(256) NOT NULL
      )
    `
    _, err = DBConnection.Exec(createTableQuery)
    if err != nil {
      log.Fatal(err)
    }
    log.Println("Tabela de usuários criada com sucesso.")

    insertUserQuery := `
      INSERT INTO users (name, email, password) VALUES
      ('John Doe', 'john.doe@example.com', 123456),
      ('Bob', 'bob@example.com', 123456),
      ('Charlie', 'charlie@example.com', 123456),
      ('Slash', 'slash@example.com', 098765),
      ('Gilmour', 'gilmour@example.com', 1255657),
      ('Steve Vai', 'steve_vai@example.com', 1255657)
    `

    _, err = DBConnection.Exec(insertUserQuery)
    if err != nil {
      log.Fatal(err)
    }
    log.Println("Usuários inseridos com sucesso.")
    return nil
  }
Enter fullscreen mode Exit fullscreen mode

O nosso SeedUsers deleta a tabela, caso exista, depois cria novamente a tabela e por último adiciona os usuários na nossa tabela. Você pode adicionar mais usuário, se precisar.

Estrutura do projeto

Para exemplificar, vamos criar alguns enpoints com essa vulnerabilidade e vamos corrigir essas vulnerabilidades, mas antes vamos estruturar nosso projeto, não vou entrar muito a fundo na estrutura, vou deixar o link do repositório aqui.

Project structure

Essa vai ser a estrutura do nosso projeto, vamos utilizar o postgreSQL como banco de dados, go chi para criar nossos enpoints, go dot env para importar nossas váriaveis de ambiente.

Separamos o main.go para iniciar nosso servidor, conexão com o banco e nossos endpoints:

  func main() {
    err := database.NewDBConnection()
    if err != nil {
      panic(err)
    }

    service.SeedUsers("test")
    r := chi.NewRouter()
    r.Get("/users", handler.GetUsersInjectHandler)
    r.Get("/users/correct", handler.GetUsersCorrectHandler)
    r.Delete("/users", handler.DeleteUserInjectHandler)
    r.Delete("/users/correct", handler.DeleteUserCorrectHandler)

    server := &http.Server{
      Addr:    ":8080",
      Handler: r,
    }
    server.ListenAndServe()
  }
Enter fullscreen mode Exit fullscreen mode

connection.go vai criar nossa conexão com o postgreSQL e disponibilizar a conexão de forma global em nossa aplicação:

  var DBConnection *sql.DB

  func NewDBConnection() error {
    err := godotenv.Load(".env")
    if err != nil {
      return errors.New("error loading .env file")
    }

    databaseURL := os.Getenv("DATABASE_URL")
    db, err := sql.Open("postgres", databaseURL)
    if err != nil {
      return err
    }
    DBConnection = db

    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Nosso user_handler.go vai ser responsável por manipular nossa request e chamar o service user_service.go.

Criando os endpoints

Vamos criar endpoints com vulnerabilidades e outro sem a vulnerabilidade.

Buscando usuários

Vamos criar um endpoint que busca o usuário pelo id, vamos chamar de GetUsersInjectHandler, esse endpoint vai ter a nossa vulnerabilidade.

user_handler.go:

  func GetUsersInjectHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
      http.Error(w, "Método não permitido", http.StatusMethodNotAllowed)
      return
    }
    id := r.URL.Query().Get("id")
    if id == "" {
      http.Error(w, "id não informado", http.StatusBadRequest)
      return
    }

    users, err := service.GetUserInject(id)
    if err != nil {
      fmt.Println(err)
      http.Error(w, "Erro ao buscar os usuários", http.StatusInternalServerError)
      return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(users); err != nil {
      http.Error(w, "Erro ao codificar os usuários para JSON", http.StatusInternalServerError)
      return
    }
  }
Enter fullscreen mode Exit fullscreen mode

user_service.go:

  func GetUserInject(id string) ([]User, error) {
    query := fmt.Sprintf("SELECT id, name, email FROM users WHERE id = %s", id)
    rows, err := database.DBConnection.Query(query)
    if err != nil {
      return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
      var user User
      if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil {
        return nil, err
      }
      users = append(users, user)
    }

    return users, nil
  }
Enter fullscreen mode Exit fullscreen mode

No service executamos nossa query, passando o id diretamente "SELECT id, name, email FROM users WHERE id = %s", id, aqui está a vulnerabilidade estamos passando o id diretamente para a consulta, sem tratar antes.

Vamos fazer uma requisição usando a extensão do HTTP Client do vscode:

GET http://localhost:8080/users?id=1 HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Recebemos o retorno esperado:

[
  {
    "id": 1,
    "name": "John Doe",
    "email": "john.doe@example.com"
  }
]
Enter fullscreen mode Exit fullscreen mode

Agora, vamos injetar o sql:

GET http://localhost:8080/users?id='1'OR'1'='1' HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Recebemos todos os usuários:

[
  {
    "id": 1,
    "name": "John Doe",
    "email": "john.doe@example.com"
  },
  {
    "id": 2,
    "name": "Bob",
    "email": "bob@example.com"
  },
  {
    "id": 3,
    "name": "Charlie",
    "email": "charlie@example.com"
  },
  {
    "id": 4,
    "name": "Slash",
    "email": "slash@example.com"
  },
  {
    "id": 5,
    "name": "Gilmour",
    "email": "gilmour@example.com"
  },
  {
    "id": 6,
    "name": "Steve Vai",
    "email": "steve_vai@example.com"
  }
]
Enter fullscreen mode Exit fullscreen mode

Como o sql injetado '1'='1' sempre vai ser true, a query sempre vai retornar todos os registros.

Deletando usuários

O sql acima é uma falha grave, mas ainda pode piorar, se a mesma vulnerabilidade estiver em uma query que deleta registros, vamos ver um exemplo:

user_handler.go:

  func DeleteUserInjectHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodDelete {
      http.Error(w, "Método não permitido", http.StatusMethodNotAllowed)
      return
    }
    id := r.URL.Query().Get("id")
    if id == "" {
      http.Error(w, "id não informado", http.StatusBadRequest)
      return
    }

    err := service.DeleteUserInject(id)
    if err != nil {
      fmt.Println(err)
      http.Error(w, "Erro ao buscar os usuários", http.StatusInternalServerError)
      return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode("Usuário deletado com sucesso"); err != nil {
      http.Error(w, "Erro ao codificar os usuários para JSON", http.StatusInternalServerError)
      return
    }
  }
Enter fullscreen mode Exit fullscreen mode

user_service.go:

  func DeleteUserInject(id string) error {
    query := fmt.Sprintf("DELETE FROM users WHERE id = %s", id)
    _, err := database.DBConnection.Exec(query)
    if err != nil {
      return err
    }

    return nil
  }
Enter fullscreen mode Exit fullscreen mode

Usamos a mesma lógica para deletar usuário pelo id:

DELETE http://localhost:8080/users?id=1 HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Ao chamar esse endpoint, o usuário com id = 1 é deletado, agora vamos injetar o sql:

DELETE http://localhost:8080/users?id='1'OR'1'='1' HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Ao chamar o endpoint com o sql injetado, perceba que todos os registros da tabela user foram deletados, já imaginou o estrago que isso pode fazer em um banco de produção?

Isso pode ser feito em qualquer query, poderíamos ter um endpoint que atualiza a senha do usuário pelo id e com injeção de sql atualizar a senha de todos os usuários, as possibilidades são enormes!

Corrigindo a vulnerabilidade

Já existem inúmeras maneiras de mitigar esse ataque, poderíamos tratar no handler e verificar se existe sql no valor do id por exemplo, mas a forma mais eficaz e correta é usar dos recursos já existentes do driver do banco de dados, no caso o pacote "database/sql" já tem esse recurso e com poucas modificações já evitamos o ataque de sql injection.

Buscando usuários

Vamos ter a mesma lógica:

user_handler.go

  func GetUsersCorrectHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
      http.Error(w, "Método não permitido", http.StatusMethodNotAllowed)
      return
    }
    id := r.URL.Query().Get("id")
    if id == "" {
      http.Error(w, "id não informado", http.StatusBadRequest)
      return
    }

    users, err := service.GetUserCorrect(id)
    if err != nil {
      fmt.Println(err)
      http.Error(w, "Erro ao buscar os usuários", http.StatusInternalServerError)
      return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(users); err != nil {
      http.Error(w, "Erro ao codificar os usuários para JSON", http.StatusInternalServerError)
      return
    }
  }
Enter fullscreen mode Exit fullscreen mode

user_service.go:

  func GetUserCorrect(id string) ([]User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    rows, err := database.DBConnection.Query(query, id)
    if err != nil {
      return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
      var user User
      if err := rows.Scan(&user.ID, &user.Name, &user.Email); err != nil {
        return nil, err
      }
      users = append(users, user)
    }

    return users, nil
  }
Enter fullscreen mode Exit fullscreen mode

Porém agora não passamos o id diretamente, passamos o marcador $1 que indica que vamos ter um parâmetro nessa posição e passamos para a query Query(query, id), dessa forma o driver já faz o que chamamos de "prepared statement" ou "sanitização", "hidratação", chame do que achar melhor, essa abordagem ajuda a separar a lógica da consulta da entrada de dados, melhorando a segurança e a integridade das operações do banco de dados.

Vamos chamar o endpoint e tentar injetar o sql:

GET http://localhost:8080/users/correct?id='1'OR'1'='1' HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Recebemos um erro e evitamos a injeção do sql!

HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Wed, 27 Dec 2023 15:53:27 GMT
Content-Length: 28
Connection: close

Erro ao buscar os usuários
Enter fullscreen mode Exit fullscreen mode

Buscando da forma correta:

GET http://localhost:8080/users/correct?id=2 HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Agora recebemos os dados do usuário corretamente.

[
  {
    "id": 2,
    "name": "Bob",
    "email": "bob@example.com"
  }
]
Enter fullscreen mode Exit fullscreen mode

Deletando usuários

Vamos usar a mesma solução usada para buscar o usuário.

user_handler.go

  func DeleteUserCorrectHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodDelete {
      http.Error(w, "Método não permitido", http.StatusMethodNotAllowed)
      return
    }
    id := r.URL.Query().Get("id")
    if id == "" {
      http.Error(w, "id não informado", http.StatusBadRequest)
      return
    }

    err := service.DeleteUserCorrect(id)
    if err != nil {
      fmt.Println(err)
      http.Error(w, "Erro ao buscar os usuários", http.StatusInternalServerError)
      return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode("Usuário deletado com sucesso"); err != nil {
      http.Error(w, "Erro ao codificar os usuários para JSON", http.StatusInternalServerError)
      return
    }
  }
Enter fullscreen mode Exit fullscreen mode

user_service.go:

func DeleteUserCorrect(id string) error {
  query := "DELETE FROM users WHERE id = $1"
  _, err := database.DBConnection.Exec(query, id)
  if err != nil {
    return err
  }

  return nil
}
Enter fullscreen mode Exit fullscreen mode

Vamos chamar o endpoint e tentar injetar o sql:

DELETE http://localhost:8080/users/correct?id='1'OR'1'='1' HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Recebemos um erro e evitamos a injeção do sql!

HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Wed, 27 Dec 2023 15:58:52 GMT
Content-Length: 28
Connection: close

Erro ao deletar os usuário
Enter fullscreen mode Exit fullscreen mode

Deletando da forma correta:

GET http://localhost:8080/users/correct?id=1 HTTP/1.1
content-type: application/json
Enter fullscreen mode Exit fullscreen mode

Deletamos apenas o usuário com o id que desejamos.

"Usuário deletado com sucesso"
Enter fullscreen mode Exit fullscreen mode

Considerações finais

Nesse post vimos como simular sql injection e como evitar a injeção de sql, existem diversas formas de mitigar esse ataque, mas usar os recursos fornecidos pelo driver do banco geralmente é o mais simples e seguro, Porém validar isso antes de chamar seu driver faz com que essa vulnerabilidade seja praticamente nula. O uso de ORM's também diminui muito as chances do sql injection acontecer, esse problema já é tratado nativamente no ORM, mas ainda pode acontecer.

Links úteis

Vou anexar alguns links abordando esse assunto

Link do repositório

repositório do projeto

Gopher credits

. . . . . . . . . . . . . . . . . . . . . .