CWE-209: Generation of Error Message Containing Sensitive Information - Go
Overview
Information disclosure vulnerabilities in Go applications occur when error messages, debug output, or diagnostic information expose sensitive technical details to users or attackers, revealing system internals, database schemas, file paths, stack traces, credentials, or business logic that aid in further attacks. Overly verbose error messages transform normal application failures into reconnaissance tools, helping attackers map system architecture, identify vulnerable components, discover SQL injection points, enumerate valid usernames, or understand internal workflows.
Common sensitive information in error messages includes database query details (table names, column names, SQL syntax errors revealing schema structure), file system paths (/home/app/config/database.yml revealing deployment structure), third-party library versions (identifying known CVEs), stack traces (exposing code organization and internal function names), configuration details (database hostnames, internal IP addresses), and business logic information (account numbers, internal IDs, pricing rules). Environment-specific details like development debug messages, panic stack traces, or database connection strings leaking to production users are particularly dangerous.
The impact varies by information disclosed: SQL error messages containing table/column names enable targeted injection attacks; file paths aid directory traversal attacks; stack traces reveal code structure for reverse engineering; version numbers help attackers find matching exploits; and verbose authentication errors enable username enumeration (distinguishing "invalid username" vs "invalid password"). Attackers combine these information leaks with other vulnerabilities to craft precise exploits rather than blind attacks.
Primary Defence: Return generic error messages to clients. Log detailed errors server-side only. Distinguish between development and production error handling. Use custom error types for internal handling while displaying user-friendly messages. Never expose stack traces, database queries, file paths, or system details to end users. Implement centralized error handling that sanitizes messages before client responses.
Common Vulnerable Patterns
Database Error Exposure
// VULNERABLE - Exposing database errors to users
package main
import (
"database/sql"
"fmt"
"net/http"
_ "github.com/go-sql-driver/mysql"
)
var db *sql.DB
func getUserProfile(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
query := "SELECT username, email, role FROM users WHERE id = ?"
var username, email, role string
// DANGEROUS: Database error returned directly to user
err := db.QueryRow(query, userID).Scan(&username, &email, &role)
if err != nil {
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "User: %s, Email: %s, Role: %s", username, email, role)
}
// ATTACK:
// GET /profile?user_id=abc
// Response: Database error: sql: converting argument $1 type: unsupported type []uint8, a slice of uint8
//
// GET /profile?user_id=999999
// Response: Database error: sql: no rows in result set
//
// Reveals:
// - Database type (SQL)
// - Table structure (users table exists with id, username, email, role columns)
// - Data types expected (numeric ID)
// - Whether IDs exist (different errors for invalid vs non-existent)
Why this is vulnerable: Database driver error messages contain technical details about the schema, query structure, and data types. sql: no rows in result set confirms the query succeeded but ID doesn't exist, enabling ID enumeration. Type conversion errors reveal expected data types. SQL syntax errors (if dynamic SQL were used) expose table/column names. Connection errors leak database hostnames or ports. Attackers use this information to craft SQL injection payloads, enumerate valid IDs, or map the database schema. Production applications should hide all database error details from users.
Panic Stack Traces in Production
// VULNERABLE - Unhandled panics exposing stack traces
import (
"fmt"
"net/http"
)
func riskyHandler(w http.ResponseWriter, r *http.Request) {
data := processUserInput(r.FormValue("input"))
// DANGEROUS: No panic recovery, will expose stack trace
result := data["key"].(string) // Panics if type assertion fails
fmt.Fprintf(w, "Result: %s", result)
}
func processUserInput(input string) map[string]interface{} {
return map[string]interface{}{
"key": 123, // Wrong type
}
}
// ATTACK:
// POST /process input=anything
//
// Response (panic stack trace):
// panic: interface conversion: interface {} is int, not string
//
// goroutine 19 [running]:
// main.riskyHandler(0xc0001a0000, 0xc00018c000)
// /home/app/src/main.go:42 +0x1a5
// net/http.HandlerFunc.ServeHTTP(0x12e1a40, 0x12f8d00, 0xc0001a0000, 0xc00018c000)
// /usr/local/go/src/net/http/server.go:2109 +0x2f
// ...
//
// Reveals:
// - File paths (/home/app/src/main.go)
// - Go version (/usr/local/go/src/)
// - Function names (riskyHandler)
// - Line numbers (exact code location)
// - Deployment structure
Why this is vulnerable: Unhandled panics in HTTP handlers print full stack traces to stderr, which often gets returned to the client by default HTTP servers or reverse proxies. Stack traces reveal absolute file paths (deployment directory structure), function names (internal code organization), line numbers (pinpointing vulnerable code), Go version (for exploit matching), and third-party library details. Attackers use this for reconnaissance: understanding code flow, identifying error-prone functions, targeting specific Go versions with known bugs, or preparing social engineering attacks with internal knowledge. Always recover from panics in production.
File System Error Exposure
// VULNERABLE - Exposing file paths in errors
import (
"fmt"
"io"
"net/http"
"os"
)
func serveConfigFile(w http.ResponseWriter, r *http.Request) {
configFile := r.URL.Query().Get("config")
// DANGEROUS: File errors expose full paths
file, err := os.Open("/etc/myapp/configs/" + configFile)
if err != nil {
http.Error(w, fmt.Sprintf("Error: %v", err), http.StatusInternalServerError)
return
}
defer file.Close()
io.Copy(w, file)
}
// ATTACK:
// GET /config?config=../../etc/passwd
//
// Response: Error: open /etc/myapp/configs/../../etc/passwd: no such file or directory
//
// Reveals:
// - Absolute path: /etc/myapp/configs/
// - Application deployment location
// - Path traversal attempt was processed (vulnerable to path injection)
// - File existence via different error messages
Why this is vulnerable: os.Open errors include the full attempted path in the error message. This reveals the application's deployment directory (/etc/myapp/configs/), confirms path traversal attempts were processed (security vulnerability), and enables file existence enumeration (different errors for permission denied vs not found). Attackers use revealed paths to craft directory traversal attacks, understand deployment architecture, and probe for sensitive file locations. Production applications must sanitize file paths and return generic "file not found" errors without revealing actual paths.
Authentication Username Enumeration
// VULNERABLE - Different errors for invalid username vs password
import (
"fmt"
"net/http"
)
func loginHandler(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
user, err := getUserByUsername(username)
if err != nil {
// DANGEROUS: Reveals username doesn't exist
http.Error(w, "Invalid username", http.StatusUnauthorized)
return
}
if !verifyPassword(user.PasswordHash, password) {
// DANGEROUS: Different message - username exists!
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
// Login successful
createSession(w, user)
}
type User struct {
Username string
PasswordHash string
}
func getUserByUsername(username string) (*User, error) {
return nil, fmt.Errorf("not found")
}
func verifyPassword(hash, password string) bool {
return false
}
func createSession(w http.ResponseWriter, user *User) {}
// ATTACK:
// POST username=alice&password=wrong
// Response: Invalid password (Alice exists!)
//
// POST username=bob&password=wrong
// Response: Invalid username (Bob doesn't exist)
//
// Attacker enumerates all valid usernames, then brute-forces only valid accounts
Why this is vulnerable: Different error messages for "username doesn't exist" vs "password is wrong" allow attackers to enumerate valid usernames. Knowing valid usernames reduces brute-force attack space: instead of trying 1 million username/password combinations, attackers enumerate 1000 valid usernames then brute-force only those 1000 accounts. This also aids phishing (targeting real users), social engineering, and password spray attacks (trying common passwords against all valid usernames). Secure authentication returns identical generic errors regardless of whether username or password is invalid.
Debug Information in Production
// VULNERABLE - Debug mode enabled in production
import (
"encoding/json"
"net/http"
)
var DebugMode = true // DANGEROUS: Left enabled in production
func apiHandler(w http.ResponseWriter, r *http.Request) {
result, err := processAPIRequest(r)
if err != nil {
if DebugMode {
// DANGEROUS: Detailed debug info in production
debugInfo := map[string]interface{}{
"error": err.Error(),
"stack": captureStack(),
"request_body": r.Body,
"database_query": getLastQuery(),
"env_vars": map[string]string{"DB_HOST": "db.internal.local"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(debugInfo)
return
}
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
func processAPIRequest(r *http.Request) (interface{}, error) {
return nil, fmt.Errorf("database connection failed")
}
func captureStack() string {
return "stack trace here"
}
func getLastQuery() string {
return "SELECT * FROM secrets WHERE id = 1"
}
// ATTACK:
// Any API error in production returns:
// {
// "error": "database connection failed",
// "stack": "...",
// "database_query": "SELECT * FROM secrets WHERE id = 1",
// "env_vars": {"DB_HOST": "db.internal.local"}
// }
//
// Reveals database schema, internal hostnames, code structure
Why this is vulnerable: Debug mode flags accidentally left enabled in production expose detailed internal information for troubleshooting. This includes stack traces (code structure), database queries (schema and data), environment variables (credentials, internal hostnames), request bodies (potentially sensitive data), and internal error messages. Attackers gain deep system knowledge without needing to exploit other vulnerabilities - the application volunteers all information needed for targeted attacks. Debug modes must be strictly environment-gated and disabled in production.
Secure Patterns
Generic Error Responses with Server-Side Logging
// SECURE - Generic user errors, detailed server logs
package main
import (
"database/sql"
"log/slog"
"net/http"
"os"
)
var (
db *sql.DB
logger *slog.Logger
)
func init() {
logger = slog.New(slog.NewJSONHandler(os.Stderr, nil))
}
func secureGetUserProfile(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
query := "SELECT username, email, role FROM users WHERE id = ?"
var username, email, role string
err := db.QueryRow(query, userID).Scan(&username, &email, &role)
if err != nil {
// SECURE: Detailed logging server-side only
logger.Error("Database query failed",
slog.String("user_id", userID),
slog.String("query", query),
slog.String("error", err.Error()),
slog.String("ip", r.RemoteAddr),
)
// SECURE: Generic error to user
http.Error(w, "Unable to retrieve user profile", http.StatusInternalServerError)
return
}
// Return data...
}
// USER sees: "Unable to retrieve user profile"
// SERVER logs: {"level":"ERROR","msg":"Database query failed","user_id":"abc","query":"SELECT...","error":"sql: converting argument...","ip":"10.0.0.1"}
Why this works: Users receive a generic "Unable to retrieve user profile" message containing no technical details about databases, queries, or error types. All diagnostic information (query, error details, user_id, IP) is logged server-side in structured format for debugging. Developers can troubleshoot effectively using logs without exposing details to attackers. Different database errors (no rows, connection failure, type mismatch) all produce the same user-facing message, preventing information leakage. This separation of concerns provides both security and debuggability.
Panic Recovery Middleware
// SECURE - Panic recovery preventing stack trace exposure
import (
"log/slog"
"net/http"
)
func panicRecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// SECURE: Log panic details server-side
logger.Error("Panic recovered",
slog.Any("panic", err),
slog.String("path", r.URL.Path),
slog.String("method", r.Method),
slog.String("ip", r.RemoteAddr),
)
// SECURE: Generic error to user (no stack trace)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func riskyHandler(w http.ResponseWriter, r *http.Request) {
data := processUserInput(r.FormValue("input"))
result := data["key"].(string) // May panic
fmt.Fprintf(w, "Result: %s", result)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/process", riskyHandler)
// SECURE: Wrap all handlers with panic recovery
http.ListenAndServe(":8080", panicRecoveryMiddleware(mux))
}
// PANIC occurs -> USER sees: "Internal server error"
// SERVER logs: panic details with request context
Why this works: The defer recover() pattern catches all panics in HTTP handlers before they bubble up to the default handler (which might expose stack traces). Panic details are logged server-side with request context (path, IP, method) for debugging. Users receive only "Internal server error" with no technical details. This middleware pattern applied globally ensures no handler can accidentally leak stack traces. Stack traces remain available to developers in logs but never reach potential attackers.
Custom Error Types with Safe Messages
// SECURE - Custom errors with user-safe messages
import (
"errors"
"fmt"
"log/slog"
"net/http"
)
// Custom error type with safe public message
type AppError struct {
Code string
Message string // User-safe message
InternalMsg string // Detailed internal message
Err error // Wrapped underlying error
}
func (e *AppError) Error() string {
return e.InternalMsg
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Error constructors
func newDatabaseError(err error) *AppError {
return &AppError{
Code: "DB_ERROR",
Message: "A database error occurred",
InternalMsg: fmt.Sprintf("Database operation failed: %v", err),
Err: err,
}
}
func newNotFoundError(resource string) *AppError {
return &AppError{
Code: "NOT_FOUND",
Message: "Resource not found",
InternalMsg: fmt.Sprintf("%s not found", resource),
Err: nil,
}
}
func secureHandler(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
user, appErr := getUser(userID)
if appErr != nil {
// SECURE: Log internal details
logger.Error("Request failed",
slog.String("code", appErr.Code),
slog.String("internal_msg", appErr.InternalMsg),
slog.Any("error", appErr.Err),
)
// SECURE: Return only safe message to user
http.Error(w, appErr.Message, http.StatusInternalServerError)
return
}
// Process user...
}
func getUser(userID string) (interface{}, *AppError) {
// Simulated database error
err := errors.New("connection timeout")
return nil, newDatabaseError(err)
}
// USER sees: "A database error occurred"
// SERVER logs: code="DB_ERROR", internal_msg="Database operation failed: connection timeout"
Why this works: Custom error types encapsulate both user-safe messages and internal details. The Message field contains generic, user-appropriate text; InternalMsg contains technical details for logging. Handlers return appErr.Message to users and log appErr.InternalMsg server-side. Error constructors (newDatabaseError, newNotFoundError) ensure consistency across the application - all database errors show the same safe message to users. This pattern centralizes error message sanitization, preventing accidental detail leakage.
Uniform Authentication Errors with Timing Safety
// SECURE - Uniform authentication errors preventing enumeration
import (
"crypto/subtle"
"net/http"
"time"
)
func secureLoginHandler(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
// Always hash even if user doesn't exist (constant time)
user, _ := getUserByUsername(username)
var storedHash []byte
if user != nil {
storedHash = []byte(user.PasswordHash)
} else {
// Dummy hash to prevent timing attack
storedHash = []byte("$2a$10$abcdefghijklmnopqrstuvwxyz1234567890ABCDEF")
}
// Always verify hash (constant time regardless of user existence)
valid := verifyPasswordConstantTime(storedHash, password)
if !valid || user == nil {
// SECURE: Same error message for all authentication failures
// Slight random delay to prevent timing analysis
time.Sleep(time.Duration(50+randInt(50)) * time.Millisecond)
logger.Info("Failed login attempt",
slog.String("username", username),
slog.String("ip", r.RemoteAddr),
)
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
logger.Info("Successful login",
slog.String("username", username),
slog.String("ip", r.RemoteAddr),
)
createSession(w, user)
}
func verifyPasswordConstantTime(hash []byte, password string) bool {
// bcrypt comparison is already constant-time
// Use subtle.ConstantTimeCompare for other comparisons
return true
}
func randInt(max int) int {
return 25
}
// ATTACK ATTEMPT:
// POST username=alice&password=wrong -> "Invalid credentials"
// POST username=bob&password=wrong -> "Invalid credentials"
// Identical messages, similar timing - no enumeration possible
Why this works: The same error message ("Invalid credentials") is returned whether the username doesn't exist, password is wrong, or account is locked. Timing attacks are mitigated by always performing hash verification even when user doesn't exist (using a dummy hash), and adding random delays to prevent precise timing analysis. Attackers cannot distinguish between invalid username and invalid password based on response content or timing. This prevents username enumeration while logging detailed information server-side (distinguishing failed login types for security monitoring).
Environment-Aware Error Handling
// SECURE - Environment-based error verbosity
import (
"encoding/json"
"net/http"
"os"
"runtime/debug"
)
var isDevelopment = os.Getenv("ENV") == "development"
type ErrorResponse struct {
Message string `json:"message"`
Code string `json:"code"`
Details map[string]interface{} `json:"details,omitempty"`
}
func handleError(w http.ResponseWriter, err error, code string) {
response := ErrorResponse{
Code: code,
}
if isDevelopment {
// DEVELOPMENT: Detailed error information
response.Message = err.Error()
response.Details = map[string]interface{}{
"stack": string(debug.Stack()),
"type": fmt.Sprintf("%T", err),
}
} else {
// PRODUCTION: Generic error message only
response.Message = getGenericMessage(code)
}
// Always log full details server-side
logger.Error("Request error",
slog.String("code", code),
slog.String("error", err.Error()),
slog.String("stack", string(debug.Stack())),
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(response)
}
func getGenericMessage(code string) string {
messages := map[string]string{
"DB_ERROR": "Database operation failed",
"AUTH_ERROR": "Authentication failed",
"NOT_FOUND": "Resource not found",
}
if msg, ok := messages[code]; ok {
return msg
}
return "An internal error occurred"
}
// DEVELOPMENT response:
// {"message":"sql: no rows in result set","code":"DB_ERROR","details":{"stack":"...","type":"*errors.errorString"}}
//
// PRODUCTION response:
// {"message":"Database operation failed","code":"DB_ERROR"}
Why this works: Environment detection (os.Getenv("ENV")) controls error verbosity. Development environments receive detailed errors (stack traces, error types) for debugging. Production environments return only generic messages mapped from error codes. Error codes themselves are safe to expose (DB_ERROR, AUTH_ERROR) as they don't reveal implementation details. Server-side logging is identical in both environments, ensuring production debugging capability. This balances developer experience with production security.