GopherPass (LDAP Selfservice Password Changer)
Assessment Date: 2025-10-09 Revision: 1 (Architecture-aligned) Assessed Version: main branch (commit 9592c1d) Assessment Scope: Comprehensive security analysis Methodology: OWASP Top 10 2021, CVSS v3.1 scoring, code review
This comprehensive security assessment evaluated GopherPass across authentication, authorization, cryptography, input validation, API security, container security, and dependency management. The application demonstrates strong foundational security with excellent cryptographic practices, secure container configuration, and thoughtful stateless design.
Security Architecture:
- WAF Layer: Rate limiting, DDoS protection, IP filtering
- Application Layer: Business logic, authentication, LDAP operations
- Library Layer:
simple-ldap-go(internally maintained at Netresearch)
This assessment has been revised to align with the organization's security architecture where rate limiting and network-level protections are handled by the WAF, not the application.
Key Strengths:
- Cryptographically secure token generation (crypto/rand, 256-bit)
- Excellent Docker security (scratch base, non-root execution, pinned images)
- Stateless authentication design (no session vulnerabilities)
- User enumeration prevention in password reset flow
- Proper structured logging without credential leakage
- WAF-based rate limiting architecture (separation of concerns)
Critical Gaps:
- Case sensitivity bug in password validation (username inclusion check)
- Missing LDAP special character handling in
simple-ldap-golibrary (needs verification) - Missing security headers (CSP, HSTS, X-Frame-Options)
- Plaintext secrets in environment variables
| Severity | Count | Action Required |
|---|---|---|
| CRITICAL | 1 | Immediate (24-48 hours) |
| HIGH | 2 | Within 30 days |
| MEDIUM | 3 | Within 90 days |
| LOW | 3 | Ongoing improvement |
| INFO | 9 | Best practices |
| DEFERRED TO WAF | 2 | WAF configuration |
CVSS Score: 7.5 (High) CWE: CWE-178 (Improper Handling of Case Sensitivity) OWASP Top 10: A07:2021 - Identification and Authentication Failures
Affected Files:
internal/rpchandler/change_password.go:64internal/web/static/js/validators.ts:170
Description: The password validation logic that prevents passwords from containing the username uses case-sensitive comparison in the password change flow, while the password reset flow correctly uses case-insensitive comparison. This inconsistency allows users to bypass frontend validation by manipulating case.
Vulnerable Code:
// change_password.go:64 - VULNERABLE (case-sensitive)
if !c.opts.PasswordCanIncludeUsername && strings.Contains(sAMAccountName, newPassword) {
return nil, fmt.Errorf("the new password must not include the username")
}
// reset_password.go:73 - CORRECT (case-insensitive)
if !h.opts.PasswordCanIncludeUsername &&
strings.Contains(strings.ToLower(newPassword), strings.ToLower(token.Username)) {
return nil, fmt.Errorf("the new password must not include the username")
}Exploit Scenario:
- User "admin" attempts to set password "Admin123!"
- Frontend check (case-sensitive) allows it
- Backend check (case-sensitive) allows it
- Password contains username despite policy
- Violates organizational password policies
Impact:
- Password policy bypass
- Weakened password strength
- Compliance violations (if policy mandates username exclusion)
Remediation: Apply case-insensitive comparison in both locations:
// Fix for change_password.go:64
if !c.opts.PasswordCanIncludeUsername &&
strings.Contains(strings.ToLower(newPassword), strings.ToLower(sAMAccountName)) {
return nil, fmt.Errorf("the new password must not include the username")
}// Fix for validators.ts:170
export const mustNotIncludeUsername = (fieldName: string) => (v: string) => {
const passwordInput = form.querySelector<HTMLInputElement>(`#username input`);
return v.toLowerCase().includes(passwordInput.value.toLowerCase())
? `${fieldName} must not include the username`
: "";
};Verification:
# Test case 1: Same case
curl -X POST http://localhost:3000/api/rpc \
-d '{"method":"change-password","params":["admin","OldPass1!","admin123!"]}'
# Expected: Error "must not include username"
# Test case 2: Different case (currently bypasses)
curl -X POST http://localhost:3000/api/rpc \
-d '{"method":"change-password","params":["admin","OldPass1!","Admin123!"]}'
# Expected after fix: Error "must not include username"Estimated Effort: 2 hours
CVSS Score: 7.2 (High) - IF not properly handled CWE: CWE-90 (Improper Neutralization of Special Elements used in an LDAP Query) OWASP Top 10: A03:2021 - Injection
Affected Component: github.com/netresearch/simple-ldap-go v1.6.0
Description:
The sAMAccountName parameter is used directly in LDAP operations. Since simple-ldap-go is internally maintained at Netresearch, any LDAP special character escaping should be implemented directly in the library rather than worked around in the application.
LDAP Special Characters That Need Escaping:
DN Special: , \ # + < > ; " = (space at beginning/end)
Filter Special: ( ) * \ NUL
Recommended Approach:
Option 1: Add to simple-ldap-go Library (Recommended)
Since you maintain this library, add proper DN and filter escaping functions:
// In simple-ldap-go library
// EscapeDN escapes special characters for LDAP DN components
func EscapeDN(input string) string {
// RFC 4514 DN escaping
replacements := map[rune]string{
',': "\\,",
'+': "\\+",
'"': "\\\"",
'\\': "\\\\",
'<': "\\<",
'>': "\\>",
';': "\\;",
'=': "\\=",
'\x00': "\\00",
}
var result strings.Builder
for i, char := range input {
// Escape leading/trailing spaces
if (char == ' ' && (i == 0 || i == len(input)-1)) ||
(char == '#' && i == 0) {
result.WriteString("\\")
result.WriteRune(char)
continue
}
if escape, exists := replacements[char]; exists {
result.WriteString(escape)
} else {
result.WriteRune(char)
}
}
return result.String()
}
// EscapeFilter escapes special characters for LDAP search filters
func EscapeFilter(input string) string {
// RFC 4515 filter escaping
replacements := map[rune]string{
'(': "\\28",
')': "\\29",
'*': "\\2a",
'\\': "\\5c",
'\x00': "\\00",
}
var result strings.Builder
for _, char := range input {
if escape, exists := replacements[char]; exists {
result.WriteString(escape)
} else {
result.WriteRune(char)
}
}
return result.String()
}Verification in simple-ldap-go:
// Add to simple-ldap-go test suite
func TestDNEscaping(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal", "normal"},
{"user,name", "user\\,name"},
{"user=admin", "user\\=admin"},
{"user\\test", "user\\\\test"},
{" leading", "\\ leading"},
{"trailing ", "trailing\\ "},
{"#start", "\\#start"},
}
for _, tt := range tests {
result := EscapeDN(tt.input)
if result != tt.expected {
t.Errorf("EscapeDN(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
func TestFilterEscaping(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal", "normal"},
{"user*", "user\\2a"},
{"(admin)", "\\28admin\\29"},
{"user\\test", "user\\5ctest"},
}
for _, tt := range tests {
result := EscapeFilter(tt.input)
if result != tt.expected {
t.Errorf("EscapeFilter(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}Option 2: Input Validation in Application (Defense-in-Depth)
Optionally add basic validation in GopherPass as defense-in-depth:
// In internal/validators/ldap.go
func ValidateLDAPUsername(username string) error {
// Whitelist approach based on organization's AD username policy
validPattern := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !validPattern.MatchString(username) {
return fmt.Errorf("invalid username format")
}
if len(username) < 1 || len(username) > 64 {
return fmt.Errorf("username length must be between 1 and 64 characters")
}
return nil
}Immediate Actions:
- Review
simple-ldap-golibrary for existing DN/filter escaping - Add escaping functions if missing (to library)
- Add comprehensive test suite for LDAP injection protection
- Optionally add input validation to GopherPass as defense-in-depth
Estimated Effort:
- Library review: 2 hours
- Add escaping functions: 4 hours
- Test suite: 2 hours
- Total: 8 hours
CVSS Score: 6.5 (Medium) CWE: CWE-16 (Configuration) OWASP Top 10: A05:2021 - Security Misconfiguration
Affected Files:
main.go:29-50(Fiber configuration)
Description: The application does not implement any HTTP security headers, leaving it vulnerable to multiple client-side attacks including XSS, clickjacking, and MIME sniffing.
Missing Headers:
| Header | Purpose | Risk if Missing |
|---|---|---|
| Content-Security-Policy (CSP) | XSS prevention | HIGH - Allows inline scripts from attacker |
| X-Frame-Options | Clickjacking prevention | MEDIUM - Application can be framed |
| Strict-Transport-Security (HSTS) | HTTPS enforcement | MEDIUM - No HTTPS guarantee |
| X-Content-Type-Options | MIME sniffing prevention | LOW - Content type confusion |
| Referrer-Policy | Referrer leakage control | LOW - Information disclosure |
Remediation:
Add Fiber helmet middleware with comprehensive security headers:
// Install: go get github.com/gofiber/helmet/v2
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/helmet/v2"
)
func main() {
app := fiber.New(fiber.Config{
AppName: "netresearch/ldap-selfservice-password-changer",
BodyLimit: 4 * 1024,
})
// Add security headers middleware
app.Use(helmet.New(helmet.Config{
// Content Security Policy
ContentSecurityPolicy: "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " + // Tailwind requires inline styles
"img-src 'self' data:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'",
// Clickjacking protection
XFrameOptions: "DENY",
// HTTPS enforcement (only in production)
HSTSMaxAge: 31536000, // 1 year
HSTSIncludeSubdomains: true,
HSTSPreload: true,
// MIME sniffing protection
XContentTypeOptions: "nosniff",
// Referrer policy
ReferrerPolicy: "strict-origin-when-cross-origin",
// Permissions policy
PermissionsPolicy: "geolocation=(), microphone=(), camera=()",
}))
// ... rest of configuration
}Verification:
curl -I https://gopherpass.example.com/
# Expected headers:
# Content-Security-Policy: default-src 'self'; script-src 'self'; ...
# X-Frame-Options: DENY
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# X-Content-Type-Options: nosniff
# Referrer-Policy: strict-origin-when-cross-originEstimated Effort: 4 hours
CVSS Score: 6.8 (Medium) CWE: CWE-256 (Plaintext Storage of a Password) OWASP Top 10: A02:2021 - Cryptographic Failures
Affected Files:
internal/options/app.go:1-169.envfiles (if present)
Description: All sensitive credentials (LDAP passwords, SMTP password, reset user password) are stored in plaintext environment variables without encryption.
Remediation Options:
Option 1: Kubernetes Secrets (Recommended for K8s)
apiVersion: v1
kind: Secret
metadata:
name: gopherpass-secrets
type: Opaque
stringData:
ldap-password: ""
smtp-password: ""
reset-user-password: ""Option 2: HashiCorp Vault Integration
func loadSecretsFromVault() (*Opts, error) {
client, _ := vault.NewClient(&vault.Config{Address: os.Getenv("VAULT_ADDR")})
client.SetToken(os.Getenv("VAULT_TOKEN"))
secret, _ := client.Logical().Read("secret/data/gopherpass")
return &Opts{
LDAPPassword: secret.Data["ldap_password"].(string),
// ...
}, nil
}Immediate Action (Short-term):
# Restrict .env file permissions
chmod 600 .env
chown app:app .env
# Ensure .env is in .gitignore
echo ".env*" >> .gitignoreEstimated Effort: 12-20 hours (depends on infrastructure choice)
CVSS Score: 5.3 (Medium) CWE: CWE-209 (Generation of Error Message Containing Sensitive Information)
Description: Raw LDAP error messages are returned to clients, potentially exposing internal infrastructure details.
Location:
change_password.go:72-return nil, errreset_password.go:123-return nil, err
Remediation:
if err := ldap.ChangePassword(...); err != nil {
log.Error("LDAP password change failed",
slog.String("user", sAMAccountName),
slog.String("error", err.Error()))
return nil, fmt.Errorf("failed to change password, please verify your current password and try again")
}Estimated Effort: 4 hours
CVSS Score: 5.0 (Medium) CWE: CWE-1284 (Improper Validation of Specified Quantity in Input)
Description: No maximum password length enforced, potentially causing LDAP buffer issues or DoS.
Remediation:
const MaxPasswordLength = 128 // LDAP typical limit
if len(newPassword) > MaxPasswordLength {
return nil, fmt.Errorf("the new password must not exceed %d characters", MaxPasswordLength)
}Estimated Effort: 2 hours
CVSS Score: 4.5 (Medium) CWE: CWE-619 (Dangling Database Cursor)
Description: Reset tokens stored in memory are lost on application restart.
Remediation Options:
- Redis-backed token store
- PostgreSQL token table
- Document limitation
Estimated Effort: 8-12 hours (if implementing persistent storage)
CVSS Score: 8.1 (High) - If no WAF protection CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts) OWASP Top 10: A07:2021 - Identification and Authentication Failures
Status: DEFERRED TO WAF
Description: Rate limiting for brute force protection should be handled at the WAF layer, not application layer. This provides consistent protection across all services and better performance.
WAF Configuration Requirements:
# Example: ModSecurity/NGINX rate limiting configuration
limit_req_zone $binary_remote_addr zone=password_change:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=password_reset:10m rate=5r/m;
location /api/rpc {
# Apply rate limiting based on JSON-RPC method
limit_req zone=password_change burst=2 nodelay;
proxy_pass http://gopherpass:3000;
}Optional: Basic Application-Level Rate Limiting
If desired for defense-in-depth or non-WAF deployments:
// Very basic rate limiting (already implemented in ratelimit/limiter.go)
// Apply to password change method in addition to reset
if body.Method == "change-password" {
if rateLimiter != nil && !rateLimiter.AllowRequest(c.IP()) {
return c.Status(429).JSON(rpc.JSONRPCResponse{
Success: false,
Data: []string{"too many requests"},
})
}
}Recommendation:
- Primary: Configure rate limiting in WAF
- Optional: Add basic app-level rate limiting for non-WAF deployments
- Document: WAF requirements in deployment guide
Estimated Effort:
- WAF configuration: 2 hours
- Optional app-level: 2 hours
- Documentation: 1 hour
CVSS Score: 4.3 (Medium) - Reduced risk due to stateless auth CWE: CWE-352 (Cross-Site Request Forgery)
Status: PARTIAL RISK - Reduced by stateless authentication
Description: No CSRF tokens, though risk is significantly reduced because:
- Every password change requires current password (proof of possession)
- Password reset requires valid token from email (out-of-band verification)
- No cookie-based authentication or persistent sessions
WAF-Level Protection:
# WAF can validate Origin/Referer headers
if ($http_origin !~ ^https://gopherpass\.example\.com$) {
return 403;
}Application-Level (Optional):
// If desired for defense-in-depth
import "github.com/gofiber/fiber/v2/middleware/csrf"
app.Use(csrf.New(csrf.Config{
KeyLookup: "header:X-CSRF-Token",
CookieSameSite: "Strict",
}))Recommendation:
- Current architecture already provides good CSRF protection via stateless auth
- WAF origin/referer validation sufficient
- Application-level CSRF optional for defense-in-depth
Remediation:
if len(email) > 254 { // RFC 5321 maximum
return genericSuccess, nil
}Estimated Effort: 1 hour
Description: Application runs HTTP, expects reverse proxy for TLS.
Status: ACCEPTABLE - Standard architecture pattern
Recommendation: Document HTTPS requirement in deployment guide.
Estimated Effort: 2 hours (documentation)
Description: LDAP/SMTP connections not tested during initialization.
Remediation:
func validateConnections(opts *Opts) error {
if err := ldap.TestConnection(opts); err != nil {
return fmt.Errorf("LDAP connection failed: %w", err)
}
if err := smtp.TestConnection(opts); err != nil {
return fmt.Errorf("SMTP connection failed: %w", err)
}
return nil
}Estimated Effort: 3 hours
- Token Generation: Uses
crypto/rand(cryptographically secure) - Token Size: 256-bit tokens (43-character base64 URL-safe)
- No Weak Algorithms: No MD5, SHA1, or weak crypto found
- Scratch Base Image: Minimal attack surface
- Non-Root Execution: User 65534:65534 (nobody:nogroup)
- Static Binary: No dynamic linking vulnerabilities
- Pinned Images: SHA256 hashes prevent supply chain attacks
- No session cookies = No session fixation/hijacking
- No JWT = No token vulnerabilities
- No persistent authentication state = Reduced attack window
Password reset flow never reveals if user exists
- Dual Validation: Frontend (UX) + Backend (security)
- Body Size Limits: 4KB prevents large payload attacks
- Structured Logging: slog with PII protection
Token store uses proper mutex synchronization
Tokens deleted after use, preventing replay attacks
Proper separation of concerns - network-level protections at WAF layer
simple-ldap-go maintained internally allows direct security improvements at source
| Category | Status | Findings |
|---|---|---|
| A01: Broken Access Control | ✅ PASS | Rate limiting handled by WAF |
| A02: Cryptographic Failures | Excellent crypto, but plaintext secrets in env | |
| A03: Injection | LDAP escaping in simple-ldap-go needs review | |
| A04: Insecure Design | ✅ PASS | Stateless design, defense in depth, WAF architecture |
| A05: Security Misconfiguration | Missing security headers | |
| A06: Vulnerable Components | No automated scanning yet | |
| A07: Auth Failures | Case sensitivity bug, WAF handles rate limiting | |
| A08: Integrity Failures | ✅ PASS | Go modules with checksums, pinned images |
| A09: Logging Failures | ✅ PASS | Structured logging, no PII leakage |
| A10: SSRF | ✅ N/A | No URL fetching functionality |
Overall Compliance: 60% (6/10 categories passing)
Timeline: 48-72 hours Effort: 10 hours
| Priority | Issue | Effort | Owner |
|---|---|---|---|
| 1 | Fix case sensitivity bug (CRITICAL-01) | 2 hours | Application Team |
| 2 | Review simple-ldap-go LDAP escaping (HIGH-01) | 2 hours | Library Maintainers |
| 3 | Add LDAP escaping to simple-ldap-go (HIGH-01) | 4 hours | Library Maintainers |
| 4 | Test LDAP injection protection (HIGH-01) | 2 hours | Application Team |
Deliverables:
- Case-insensitive username comparison in change_password.go and validators.ts
- LDAP DN and filter escaping in simple-ldap-go library
- LDAP injection test suite in simple-ldap-go
- Hotfix deployed to production
Timeline: 30 days Effort: 16-20 hours
| Priority | Issue | Effort | Owner |
|---|---|---|---|
| 5 | Implement security headers (HIGH-02) | 4 hours | Application Team |
| 6 | Secrets management integration (HIGH-03) | 12-20 hours | DevOps Team |
| 7 | Document WAF requirements (WAF-01) | 2 hours | DevOps Team |
Deliverables:
- Fiber helmet middleware with CSP, HSTS, X-Frame-Options
- Kubernetes Secrets or Vault integration
- WAF configuration documentation for rate limiting
- Deployment guide with security requirements
Timeline: 90 days Effort: 14-26 hours
| Priority | Issue | Effort |
|---|---|---|
| 8 | Wrap LDAP errors (MEDIUM-01) | 4 hours |
| 9 | Add max password length (MEDIUM-02) | 2 hours |
| 10 | Persistent token store (MEDIUM-03) - Optional | 8-12 hours |
| 11 | Automated vuln scanning | 4 hours |
| 12 | Optional app-level rate limiting (WAF-01) | 2 hours |
Timeline: Ongoing Effort: 2-4 hours per month
| Priority | Issue | Effort |
|---|---|---|
| 13 | Email length validation (LOW-01) | 1 hour |
| 14 | HTTPS documentation (LOW-02) | 2 hours |
| 15 | Connection validation (LOW-03) | 3 hours |
| 16 | Security monitoring | 2 hours/month |
# 1. Test case sensitivity fix
go test -v ./internal/rpchandler -run TestPasswordUsernameInclusion
# 2. Test LDAP escaping (in simple-ldap-go repository)
cd ~/go/src/github.com/netresearch/simple-ldap-go
go test -v . -run TestDNEscaping
go test -v . -run TestFilterEscaping
# 3. Test security headers
curl -I http://localhost:3000/ | grep -E "Content-Security|X-Frame|HSTS"
# 4. Run vulnerability scan
govulncheck ./...
# 5. Test WAF rate limiting (if configured)
for i in {1..10}; do
curl -X POST https://gopherpass.example.com/api/rpc \
-d '{"method":"change-password","params":["test","wrong","pass"]}'
doneRequired WAF Rules:
- Rate limiting: 5 requests/15min per IP for /api/rpc
- DDoS protection: SYN flood, HTTP flood protection
- IP allowlisting/blocklisting for administrative access
- Geographic restrictions (if applicable)
- Request size limits (already 4KB in app, enforce at WAF too)
Optional WAF Rules:
- Bot protection (CAPTCHA for suspicious patterns)
- Signature-based attack detection (ModSecurity Core Rule Set)
- Header validation (Origin/Referer checks)
Required in Library:
- DN escaping for RDN components (RFC 4514)
- Filter escaping for search filters (RFC 4515)
- Proper handling of NUL bytes
- Test suite for LDAP injection vectors
Optional in Library:
- Username format validation (whitelist)
- Maximum input length checks
- Audit logging for security events
# Prometheus metrics recommendations
- gopherpass_authentication_failures_total{endpoint}
- gopherpass_password_change_attempts_total{result}
- gopherpass_password_reset_requests_total{result}
- gopherpass_ldap_errors_total{type}
- gopherpass_token_store_size# WAF metrics to monitor
- waf_rate_limit_triggers_total{ip,endpoint}
- waf_blocked_requests_total{rule,ip}
- waf_requests_total{status,method}Infrastructure:
- WAF configured with rate limiting rules
- HTTPS/TLS enabled (reverse proxy)
- Secrets in Kubernetes Secrets or Vault
- LDAPS enforced for ActiveDirectory connections
- SMTP over TLS enabled
Application:
- Security headers middleware enabled
- HSTS enabled (only in production)
- Environment-specific configuration validated
- Connection validation on startup
Monitoring:
- Authentication failure alerting
- LDAP error alerting
- Token store size monitoring
- WAF rate limit trigger alerts
- govulncheck - Go vulnerability scanner
- ModSecurity - WAF engine
GopherPass demonstrates strong foundational security with excellent cryptographic practices, secure container configuration, and proper architectural separation of concerns (WAF for rate limiting, application for business logic).
Revised Risk Assessment:
- 1 CRITICAL finding requiring immediate fix (case sensitivity bug)
- 2 HIGH findings requiring attention (LDAP escaping in library, security headers)
- 2 findings deferred to WAF (rate limiting, CSRF - proper architectural placement)
Key Architectural Strengths:
- WAF-based rate limiting (better performance and consistency)
- Internal library maintenance (fix at source, not workaround)
- Stateless authentication (eliminates session vulnerabilities)
- Container security best practices
Immediate Actions (This Week):
- Fix case sensitivity bug (2 hours)
- Review and enhance simple-ldap-go LDAP escaping (6 hours)
- Document WAF configuration requirements (2 hours)
Following this revised roadmap will elevate security posture from MODERATE to STRONG within 30 days, with proper architectural alignment between application and infrastructure layers.
Assessment Conducted By: Claude (Security Analysis Agent) Report Generated: 2025-10-09 (Revised) Architecture Review: Aligned with WAF-based security model Next Review Recommended: 2025-11-09 (30 days)
This report is confidential and intended solely for the GopherPass development team and Netresearch infrastructure/security teams.