MFormations
Modern Go Engineering

Chapitre 12

12 - Sécurité Go

12 - Sécurité Go

Cours 12 : Sécurité Go

1. OWASP Top 10 pour Go

L'OWASP Top 10 liste les risques de sécurité les plus critiques pour les applications web. Appliqué à Go :

  1. Injection (SQL, NoSQL, OS, LDAP)
  2. Broken Authentication (JWT, sessions)
  3. Sensitive Data Exposure (TLS, encryption)
  4. XML External Entities (XXE)
  5. Broken Access Control (RBAC, permissions)
  6. Security Misconfiguration (CORS, headers)
  7. Cross-Site Scripting (XSS)
  8. Insecure Deserialization
  9. Using Components with Known Vulnerabilities
  10. Insufficient Logging & Monitoring

Go est conçu pour être sécurisé : buffer overflow protection, bounds checking, pas de pointer arithmetic. Mais les erreurs de logique restent possibles.

2. SQL Injection et Prepared Statements

2.1 Le problème

// ❌ Vulnérable : concaténation de requêtes
func getUserVulnerable(db *sql.DB, id string) (*User, error) {
    query := fmt.Sprintf("SELECT id, name, email FROM users WHERE id = '%s'", id)
    row := db.QueryRow(query)
    // Si id = "' OR '1'='1" → toutes les données fuient
    // Si id = "'; DROP TABLE users;--" → destruction
    ...
}

2.2 Prepared Statements (la solution)

// ✅ Sécurisé : paramètres positionnels
func getUser(ctx context.Context, db *sql.DB, id string) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    row := db.QueryRowContext(ctx, query, id)

    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, err
    }
    return &u, nil
}

// ✅ Avec pgx (PostgreSQL)
func getUserPG(ctx context.Context, conn *pgx.Conn, id string) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    row := conn.QueryRow(ctx, query, id)
    // pgx supporte aussi les named parameters
    // row := conn.QueryRow(ctx, "SELECT * FROM users WHERE id = @id", pgx.NamedArgs{"id": id})

    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, err
    }
    return &u, nil
}

// ✅ Batch insert sécurisé
func createUsers(ctx context.Context, db *sql.DB, users []User) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    stmt, err := tx.PrepareContext(ctx,
        "INSERT INTO users (name, email) VALUES ($1, $2)")
    if err != nil {
        return err
    }
    defer stmt.Close()

    for _, u := range users {
        if _, err := stmt.ExecContext(ctx, u.Name, u.Email); err != nil {
            return err
        }
    }
    return tx.Commit()
}

2.3 NoSQL Injection

// ❌ Vulnérable MongoDB
func getUserMongoVulnerable(collection *mongo.Collection, id string) (*User, error) {
    filter := bson.M{"name": bson.M{"$ne": ""}}
    // Si un paramètre utilisateur est injecté :
    // filter := bson.M{"$where": fmt.Sprintf("this.name == '%s'", userInput)}
    ...
}

// ✅ Sécurisé : utiliser BSON structuré
func getUserMongo(ctx context.Context, collection *mongo.Collection, id primitive.ObjectID) (*User, error) {
    filter := bson.D{{Key: "_id", Value: id}}
    var user User
    err := collection.FindOne(ctx, filter).Decode(&user)
    return &user, err
}

3. XSS et Template Escaping

Go's html/template escape automatiquement les sorties :

import "html/template"

// ✅ html/template escape automatiquement
func renderProfile(w http.ResponseWriter, name string) {
    tmpl := template.Must(template.New("profile").Parse(
        `<div>Hello, {{.Name}}!</div>`,
    ))
    tmpl.Execute(w, struct{ Name string }{name})
    // Si name = "<script>alert('xss')</script>"
    // Sortie : &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;
}

// ⚠️ text/template N'ESCAPE PAS
func renderUnsafe(w http.ResponseWriter, name string) {
    tmpl := template.Must(template.New("name").Parse(
        `<div>Hello, {{.}}!</div>`,
    ))
    tmpl.Execute(w, name)
    // ❌ XSS possible !
}

// ✅ Safe HTML avec template.HTML (attention !)
func renderSafeHTML(w http.ResponseWriter, content template.HTML) {
    tmpl := template.Must(template.New("page").Parse(
        `<div>{{.Content}}</div>`,
    ))
    tmpl.Execute(w, struct{ Content template.HTML }{content})
    // Utiliser template.HTML seulement pour du contenu approuvé
}

// ✅ Context-aware escaping
func renderContextAware(w http.ResponseWriter, url, js, css string) {
    tmpl := template.Must(template.New("page").Parse(`
        <a href="{{.URL}}">Link</a>       <!-- URL escaping -->
        <script>var x = '{{.JS}}'</script> <!-- JS escaping -->
        <style>body { color: {{.CSS}} }</style> <!-- CSS escaping -->
    `))
    tmpl.Execute(w, struct {
        URL, JS, CSS string
    }{url, js, css})
}

4. CSRF Protection

package csrf

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "net/http"
)

type CSRFProtection struct {
    secret []byte
}

func NewCSRFProtection(secret []byte) *CSRFProtection {
    return &CSRFProtection{secret: secret}
}

func (c *CSRFProtection) GenerateToken() string {
    // Générer un nonce aléatoire
    nonce := make([]byte, 32)
    rand.Read(nonce)
    
    // HMAC-like token
    hash := sha256.Sum256(append(c.secret, nonce...))
    token := append(nonce, hash[:]...)
    return base64.URLEncoding.EncodeToString(token)
}

func (c *CSRFProtection) ValidateToken(token string) bool {
    data, err := base64.URLEncoding.DecodeString(token)
    if err != nil || len(data) != 64 {
        return false
    }
    nonce := data[:32]
    hash := data[32:]
    
    expected := sha256.Sum256(append(c.secret, nonce...))
    return subtle.ConstantTimeCompare(hash, expected[:]) == 1
}

func (c *CSRFProtection) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method == http.MethodGet || r.Method == http.MethodHead {
            next.ServeHTTP(w, r)
            return
        }
        
        token := r.Header.Get("X-CSRF-Token")
        if token == "" {
            http.Error(w, "CSRF token required", http.StatusForbidden)
            return
        }
        if !c.ValidateToken(token) {
            http.Error(w, "invalid CSRF token", http.StatusForbidden)
            return
        }
        next.ServeHTTP(w, r)
    })
}

5. Authentification

5.1 JWT

package auth

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    UserID string   `json:"user_id"`
    Roles  []string `json:"roles"`
    jwt.RegisteredClaims
}

type JWTAuth struct {
    secret     []byte
    issuer     string
    expiry     time.Duration
}

func NewJWTAuth(secret string, issuer string, expiry time.Duration) *JWTAuth {
    return &JWTAuth{
        secret: []byte(secret),
        issuer: issuer,
        expiry: expiry,
    }
}

func (a *JWTAuth) GenerateToken(userID string, roles []string) (string, error) {
    claims := &Claims{
        UserID: userID,
        Roles:  roles,
        RegisteredClaims: jwt.RegisteredClaims{
            Issuer:    a.issuer,
            Subject:   userID,
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(a.expiry)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            NotBefore: jwt.NewNumericDate(time.Now()),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(a.secret)
}

func (a *JWTAuth) ValidateToken(tokenStr string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &Claims{},
        func(token *jwt.Token) (any, error) {
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
            }
            return a.secret, nil
        },
        jwt.WithIssuer(a.issuer),
        jwt.WithValidMethods([]string{"HS256"}),
    )
    if err != nil {
        return nil, err
    }

    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}

// JWT Middleware
func (a *JWTAuth) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        header := r.Header.Get("Authorization")
        if header == "" {
            http.Error(w, "missing authorization header", http.StatusUnauthorized)
            return
        }

        tokenStr := strings.TrimPrefix(header, "Bearer ")
        if tokenStr == header {
            http.Error(w, "invalid authorization format", http.StatusUnauthorized)
            return
        }

        claims, err := a.ValidateToken(tokenStr)
        if err != nil {
            http.Error(w, "invalid token: "+err.Error(), http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), "claims", claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

5.2 OAuth2

package oauth

import (
    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

type OAuth2Config struct {
    config *oauth2.Config
    state  string
}

func NewGoogleOAuth2(clientID, clientSecret, redirectURL string) *OAuth2Config {
    return &OAuth2Config{
        config: &oauth2.Config{
            ClientID:     clientID,
            ClientSecret: clientSecret,
            RedirectURL:  redirectURL,
            Scopes:       []string{"openid", "profile", "email"},
            Endpoint:     google.Endpoint,
        },
    }
}

func (o *OAuth2Config) AuthURL() string {
    return o.config.AuthCodeURL(o.state, oauth2.AccessTypeOffline)
}

func (o *OAuth2Config) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
    return o.config.Exchange(ctx, code)
}

func (o *OAuth2Config) ValidateToken(ctx context.Context, token *oauth2.Token) (*Claims, error) {
    client := o.config.Client(ctx, token)
    resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var claims Claims
    if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
        return nil, err
    }
    return &claims, nil
}

6. Password Hashing

6.1 bcrypt

package password

import (
    "golang.org/x/crypto/bcrypt"
)

const bcryptCost = 12 // 2^12 iterations (~250ms)

func Hash(password string) (string, error) {
    bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
    if err != nil {
        return "", fmt.Errorf("hash password: %w", err)
    }
    return string(bytes), nil
}

func Verify(password, hash string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
    return err == nil
}

// Validation des mots de passe
func ValidatePassword(password string) error {
    if len(password) < 8 {
        return fmt.Errorf("password must be at least 8 characters")
    }
    if len(password) > 128 {
        return fmt.Errorf("password must be at most 128 characters")
    }
    
    hasUpper := false
    hasLower := false
    hasDigit := false
    hasSpecial := false
    
    for _, c := range password {
        switch {
        case unicode.IsUpper(c):
            hasUpper = true
        case unicode.IsLower(c):
            hasLower = true
        case unicode.IsDigit(c):
            hasDigit = true
        case unicode.IsPunct(c) || unicode.IsSymbol(c):
            hasSpecial = true
        }
    }
    
    if !hasUpper || !hasLower || !hasDigit || !hasSpecial {
        return fmt.Errorf("password must contain upper, lower, digit, and special character")
    }
    return nil
}

6.2 Argon2 (recommandé)

package argon2

import (
    "crypto/rand"
    "crypto/subtle"
    "encoding/base64"
    "fmt"
    
    "golang.org/x/crypto/argon2"
)

type Argon2Params struct {
    Time    uint32
    Memory  uint32
    Threads uint8
    KeyLen  uint32
    SaltLen uint32
}

var DefaultParams = &Argon2Params{
    Time:    3,
    Memory:  64 * 1024, // 64 MB
    Threads: 4,
    KeyLen:  32,
    SaltLen: 16,
}

func Hash(password string, p *Argon2Params) (string, error) {
    if p == nil {
        p = DefaultParams
    }

    salt := make([]byte, p.SaltLen)
    if _, err := rand.Read(salt); err != nil {
        return "", fmt.Errorf("generate salt: %w", err)
    }

    hash := argon2.IDKey([]byte(password), salt, p.Time, p.Memory, p.Threads, p.KeyLen)

    // Format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
    b64Salt := base64.RawStdEncoding.EncodeToString(salt)
    b64Hash := base64.RawStdEncoding.EncodeToString(hash)

    encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
        argon2.Version, p.Memory, p.Time, p.Threads, b64Salt, b64Hash)

    return encoded, nil
}

func Verify(password, encoded string) (bool, error) {
    parts := strings.Split(encoded, "$")
    if len(parts) != 6 {
        return false, fmt.Errorf("invalid hash format")
    }

    var version int
    var memory uint32
    var time uint32
    var threads uint8

    _, err := fmt.Sscanf(parts[2], "v=%d", &version)
    if err != nil {
        return false, fmt.Errorf("parse version: %w", err)
    }

    _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
    if err != nil {
        return false, fmt.Errorf("parse params: %w", err)
    }

    salt, err := base64.RawStdEncoding.DecodeString(parts[4])
    if err != nil {
        return false, fmt.Errorf("decode salt: %w", err)
    }

    hash, err := base64.RawStdEncoding.DecodeString(parts[5])
    if err != nil {
        return false, fmt.Errorf("decode hash: %w", err)
    }

    expected := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
    return subtle.ConstantTimeCompare(hash, expected) == 1, nil
}

7. TLS/SSL avec crypto/tls

package tlsconfig

import (
    "crypto/tls"
    "crypto/x509"
)

func NewTLSConfig(certFile, keyFile, caFile string) (*tls.Config, error) {
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        return nil, fmt.Errorf("load cert: %w", err)
    }

    caCert, err := os.ReadFile(caFile)
    if err != nil {
        return nil, fmt.Errorf("read CA: %w", err)
    }

    caCertPool := x509.NewCertPool()
    caCertPool.AppendCertsFromPEM(caCert)

    return &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      caCertPool,
        MinVersion:   tls.VersionTLS12,
        MaxVersion:   tls.VersionTLS13,
        CurvePreferences: []tls.CurveID{
            tls.X25519,
            tls.CurveP256,
        },
        CipherSuites: []uint16{
            tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
            tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
            tls.TLS_AES_256_GCM_SHA384, // TLS 1.3
        },
        PreferServerCipherSuites: true,
        SessionTicketsDisabled:   false,
        ClientAuth:               tls.RequireAndVerifyClientCert,
    }, nil
}

// HTTPS Server
func startSecureServer(addr string, tlsConfig *tls.Config, handler http.Handler) error {
    server := &http.Server{
        Addr:      addr,
        Handler:   handler,
        TLSConfig: tlsConfig,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }
    return server.ListenAndServeTLS("", "") // certs already in config
}

8. CSPRNG avec crypto/rand

package csprng

import (
    "crypto/rand"
    "encoding/hex"
    "math/big"
)

// Générer des tokens sécurisés
func GenerateToken(length int) (string, error) {
    bytes := make([]byte, length)
    if _, err := rand.Read(bytes); err != nil {
        return "", fmt.Errorf("generate random: %w", err)
    }
    return hex.EncodeToString(bytes), nil
}

// API Key sécurisée
func GenerateAPIKey() (string, error) {
    bytes := make([]byte, 32)
    if _, err := rand.Read(bytes); err != nil {
        return "", err
    }
    return base64.URLEncoding.EncodeToString(bytes), nil
}

// OTP sécurisé
func GenerateOTP(length int) (string, error) {
    code := make([]byte, length)
    for i := range code {
        n, err := rand.Int(rand.Reader, big.NewInt(10))
        if err != nil {
            return "", err
        }
        code[i] = byte('0' + n.Int64())
    }
    return string(code), nil
}

// ⚠️ NE PAS UTILISER math/rand pour la sécurité
// math/rand est prévisible !
func insecureToken() string {
    return fmt.Sprintf("%x", rand.Int()) // PRÉVISIBLE !
}

9. Secure Headers

package secure

import "net/http"

type SecureHeaders struct {
    CSP         string
    HSTS        string
    XFrameOpts  string
    XContentType string
    ReferrerPolicy string
    Permissions  string
}

var DefaultHeaders = SecureHeaders{
    CSP: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'",
    HSTS: "max-age=31536000; includeSubDomains; preload",
    XFrameOpts: "DENY",
    XContentType: "nosniff",
    ReferrerPolicy: "strict-origin-when-cross-origin",
    Permissions: "camera=(), microphone=(), geolocation=()",
}

func HeadersMiddleware(h SecureHeaders) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Content-Security-Policy", h.CSP)
            w.Header().Set("Strict-Transport-Security", h.HSTS)
            w.Header().Set("X-Frame-Options", h.XFrameOpts)
            w.Header().Set("X-Content-Type-Options", h.XContentType)
            w.Header().Set("Referrer-Policy", h.ReferrerPolicy)
            w.Header().Set("Permissions-Policy", h.Permissions)
            w.Header().Set("X-XSS-Protection", "0") // obsolete mais pas de mal
            next.ServeHTTP(w, r)
        })
    }
}

10. Dépendances et govulncheck

# Installation
go install golang.org/x/vuln/cmd/govulncheck@latest

# Analyse
govulncheck ./...

# Exemple de sortie :
# Vulnerability #1: GO-2024-XXXX
#   HTTP/2 rapid reset attack in net/http
#   Affects: go1.22.0, go1.22.1
#   CVSS: 7.5 HIGH
#   Call stack in main.go:42

# Intégration CI
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
  vulncheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
      - run: govulncheck ./...

11. SBOM (Software Bill of Materials)

# Générer un SBOM avec syft
syft packages . -o cyclonedx > sbom.cdx.json

# Générer un SBOM Go
go list -json -m all > go.mod.sbom

# Vérifier les vulnérabilités dans le SBOM
grype sbom.cdx.json

# Intégration CI
# go.mod.sbom généré à chaque release
# Scan avec Trivy
trivy fs --format sarif -o trivy-results.sarif .

12. Input Validation

package validation

import (
    "net/mail"
    "regexp"
    "strings"
    "unicode/utf8"
)

var (
    emailRegex    = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
    usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
    slugRegex     = regexp.MustCompile(`^[a-z0-9-]+$`)
)

type Validator struct {
    errors map[string]string
}

func NewValidator() *Validator {
    return &Validator{errors: make(map[string]string)}
}

func (v *Validator) Required(field, value string) *Validator {
    if strings.TrimSpace(value) == "" {
        v.errors[field] = "is required"
    }
    return v
}

func (v *Validator) MinLength(field, value string, min int) *Validator {
    if utf8.RuneCountInString(value) < min {
        v.errors[field] = fmt.Sprintf("minimum %d characters", min)
    }
    return v
}

func (v *Validator) MaxLength(field, value string, max int) *Validator {
    if utf8.RuneCountInString(value) > max {
        v.errors[field] = fmt.Sprintf("maximum %d characters", max)
    }
    return v
}

func (v *Validator) Email(field, value string) *Validator {
    if _, err := mail.ParseAddress(value); err != nil {
        v.errors[field] = "invalid email address"
    }
    return v
}

func (v *Validator) Username(field, value string) *Validator {
    if !usernameRegex.MatchString(value) {
        v.errors[field] = "must be 3-32 chars: letters, digits, underscore, hyphen"
    }
    return v
}

func (v *Validator) HasErrors() bool {
    return len(v.errors) > 0
}

func (v *Validator) Errors() map[string]string {
    return v.errors
}

Points Clés

  1. Prepared statements systématiques pour SQL
  2. html/template (pas text/template) pour l'output HTML
  3. bcrypt ou argon2 pour les mots de passe
  4. crypto/rand (pas math/rand) pour les tokens
  5. JWT avec validation stricte (algo, issuer, expiry)
  6. TLS 1.2+ minimum
  7. govulncheck dans la CI
  8. SBOM pour chaque release