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 :
- Injection (SQL, NoSQL, OS, LDAP)
- Broken Authentication (JWT, sessions)
- Sensitive Data Exposure (TLS, encryption)
- XML External Entities (XXE)
- Broken Access Control (RBAC, permissions)
- Security Misconfiguration (CORS, headers)
- Cross-Site Scripting (XSS)
- Insecure Deserialization
- Using Components with Known Vulnerabilities
- 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 : <script>alert('xss')</script>
}
// ⚠️ 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
- Prepared statements systématiques pour SQL
- html/template (pas text/template) pour l'output HTML
- bcrypt ou argon2 pour les mots de passe
- crypto/rand (pas math/rand) pour les tokens
- JWT avec validation stricte (algo, issuer, expiry)
- TLS 1.2+ minimum
- govulncheck dans la CI
- SBOM pour chaque release