Skip to content

Commit debcb5c

Browse files
committed
fix: hide internal server error details from users
Signed-off-by: Ivan Zvyagintsev <[email protected]>
1 parent d1b2722 commit debcb5c

File tree

6 files changed

+349
-15
lines changed

6 files changed

+349
-15
lines changed

server/deviceflowhandlers.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import (
1111
"strings"
1212
"time"
1313

14-
"golang.org/x/net/html"
15-
1614
"github.com/dexidp/dex/storage"
1715
)
1816

@@ -300,9 +298,11 @@ func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) {
300298

301299
// Authorization redirect callback from OAuth2 auth flow.
302300
if errMsg := r.FormValue("error"); errMsg != "" {
303-
// escape the message to prevent cross-site scripting
304-
msg := html.EscapeString(errMsg + ": " + r.FormValue("error_description"))
305-
http.Error(w, msg, http.StatusBadRequest)
301+
// Log the error details but don't expose them to the user
302+
s.logger.ErrorContext(r.Context(), "OAuth2 authorization error",
303+
"error", errMsg,
304+
"error_description", r.FormValue("error_description"))
305+
s.renderError(r, w, http.StatusBadRequest, "Authorization failed. Please try again.")
306306
return
307307
}
308308

@@ -392,7 +392,8 @@ func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) {
392392
}
393393

394394
default:
395-
http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest)
395+
s.logger.ErrorContext(r.Context(), "unsupported method in device callback", "method", r.Method)
396+
s.renderError(r, w, http.StatusBadRequest, ErrMsgMethodNotAllowed)
396397
return
397398
}
398399
}

server/errors.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package server
2+
3+
// Safe error messages for user-facing responses.
4+
// These messages are intentionally generic to avoid leaking internal details.
5+
// All actual error details should be logged server-side.
6+
7+
const (
8+
// ErrMsgLoginError is a generic login error message shown to users.
9+
// Used when authentication fails due to internal server errors.
10+
ErrMsgLoginError = "Login error. Please contact your administrator or try again later."
11+
12+
// ErrMsgAuthenticationFailed is shown when callback/SAML authentication fails.
13+
ErrMsgAuthenticationFailed = "Authentication failed. Please contact your administrator or try again later."
14+
15+
// ErrMsgInternalServerError is a generic internal server error message.
16+
ErrMsgInternalServerError = "Internal server error. Please contact your administrator or try again later."
17+
18+
// ErrMsgDatabaseError is shown when database operations fail.
19+
ErrMsgDatabaseError = "A database error occurred. Please try again later."
20+
21+
// ErrMsgInvalidRequest is shown when request parsing fails.
22+
ErrMsgInvalidRequest = "Invalid request. Please try again."
23+
24+
// ErrMsgMethodNotAllowed is shown when an unsupported HTTP method is used.
25+
ErrMsgMethodNotAllowed = "Method not allowed."
26+
)

server/errors_test.go

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
package server
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestErrorMessagesDoNotLeakInternalDetails verifies that error responses
15+
// do not contain internal error details that could be exploited by attackers.
16+
func TestErrorMessagesDoNotLeakInternalDetails(t *testing.T) {
17+
// List of sensitive patterns that should never appear in user-facing errors
18+
sensitivePatterns := []string{
19+
"panic",
20+
"runtime error",
21+
"nil pointer",
22+
"stack trace",
23+
"goroutine",
24+
".go:", // file paths like "server.go:123"
25+
"sql:", // SQL errors
26+
"connection", // Connection errors
27+
"timeout", // Unless it's a user-friendly timeout message
28+
"ECONNREFUSED",
29+
"EOF",
30+
"broken pipe",
31+
}
32+
33+
tests := []struct {
34+
name string
35+
path string
36+
method string
37+
body string
38+
contentType string
39+
setupFunc func(t *testing.T, s *Server)
40+
checkFunc func(t *testing.T, resp *http.Response, body string)
41+
}{
42+
{
43+
name: "Invalid authorization request parse error",
44+
path: "/auth",
45+
method: "POST",
46+
body: "invalid%body",
47+
contentType: "application/x-www-form-urlencoded",
48+
checkFunc: func(t *testing.T, resp *http.Response, body string) {
49+
// Should return a safe error message, not the parse error details
50+
for _, pattern := range sensitivePatterns {
51+
require.NotContains(t, body, pattern,
52+
"Response should not contain sensitive pattern: %s", pattern)
53+
}
54+
},
55+
},
56+
{
57+
name: "Invalid callback state",
58+
path: "/callback?state=invalid_state",
59+
method: "GET",
60+
checkFunc: func(t *testing.T, resp *http.Response, body string) {
61+
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
62+
// Should not leak storage error details
63+
require.NotContains(t, body, "storage")
64+
require.NotContains(t, body, "not found")
65+
},
66+
},
67+
{
68+
name: "Invalid token request",
69+
path: "/token",
70+
method: "POST",
71+
body: "grant_type=authorization_code&code=invalid",
72+
contentType: "application/x-www-form-urlencoded",
73+
checkFunc: func(t *testing.T, resp *http.Response, body string) {
74+
// Token endpoint returns JSON errors which is correct OAuth2 behavior
75+
// Just verify no internal details leak
76+
for _, pattern := range sensitivePatterns {
77+
require.NotContains(t, body, pattern,
78+
"Response should not contain sensitive pattern: %s", pattern)
79+
}
80+
},
81+
},
82+
{
83+
name: "Invalid introspection request - no token",
84+
path: "/token/introspect",
85+
method: "POST",
86+
body: "",
87+
contentType: "application/x-www-form-urlencoded",
88+
checkFunc: func(t *testing.T, resp *http.Response, body string) {
89+
for _, pattern := range sensitivePatterns {
90+
require.NotContains(t, body, pattern,
91+
"Response should not contain sensitive pattern: %s", pattern)
92+
}
93+
},
94+
},
95+
{
96+
name: "Device flow invalid user code",
97+
path: "/device/auth/verify_code",
98+
method: "POST",
99+
body: "user_code=INVALID",
100+
checkFunc: func(t *testing.T, resp *http.Response, body string) {
101+
for _, pattern := range sensitivePatterns {
102+
require.NotContains(t, body, pattern,
103+
"Response should not contain sensitive pattern: %s", pattern)
104+
}
105+
},
106+
},
107+
}
108+
109+
for _, tc := range tests {
110+
t.Run(tc.name, func(t *testing.T) {
111+
httpServer, s := newTestServer(t, nil)
112+
defer httpServer.Close()
113+
114+
if tc.setupFunc != nil {
115+
tc.setupFunc(t, s)
116+
}
117+
118+
var reqBody io.Reader
119+
if tc.body != "" {
120+
reqBody = strings.NewReader(tc.body)
121+
}
122+
123+
req := httptest.NewRequest(tc.method, tc.path, reqBody)
124+
if tc.contentType != "" {
125+
req.Header.Set("Content-Type", tc.contentType)
126+
}
127+
128+
rr := httptest.NewRecorder()
129+
s.ServeHTTP(rr, req)
130+
131+
resp := rr.Result()
132+
defer resp.Body.Close()
133+
134+
bodyBytes, err := io.ReadAll(resp.Body)
135+
require.NoError(t, err)
136+
body := string(bodyBytes)
137+
138+
if tc.checkFunc != nil {
139+
tc.checkFunc(t, resp, body)
140+
}
141+
})
142+
}
143+
}
144+
145+
// TestLoginErrorMessageIsSafe verifies that the login error page
146+
// shows a safe, user-friendly message.
147+
func TestLoginErrorMessageIsSafe(t *testing.T) {
148+
httpServer, s := newTestServer(t, nil)
149+
defer httpServer.Close()
150+
151+
// Create a request that will trigger a login error
152+
rr := httptest.NewRecorder()
153+
req := httptest.NewRequest("GET", "/auth/nonexistent/login?state=test", nil)
154+
s.ServeHTTP(rr, req)
155+
156+
resp := rr.Result()
157+
defer resp.Body.Close()
158+
159+
body, _ := io.ReadAll(resp.Body)
160+
bodyStr := string(body)
161+
162+
// Should not contain error stack traces or internal details
163+
require.NotContains(t, bodyStr, "panic")
164+
require.NotContains(t, bodyStr, ".go:")
165+
require.NotContains(t, bodyStr, "goroutine")
166+
}
167+
168+
// TestCallbackErrorMessageIsSafe verifies that callback errors
169+
// do not leak internal details.
170+
func TestCallbackErrorMessageIsSafe(t *testing.T) {
171+
httpServer, s := newTestServer(t, nil)
172+
defer httpServer.Close()
173+
174+
// Test OAuth2 callback with invalid state
175+
rr := httptest.NewRecorder()
176+
req := httptest.NewRequest("GET", "/callback?code=test&state=invalid", nil)
177+
s.ServeHTTP(rr, req)
178+
179+
resp := rr.Result()
180+
defer resp.Body.Close()
181+
182+
body, _ := io.ReadAll(resp.Body)
183+
bodyStr := string(body)
184+
185+
// Should not contain storage error details
186+
require.NotContains(t, bodyStr, "storage.ErrNotFound")
187+
require.NotContains(t, bodyStr, "database")
188+
}
189+
190+
// TestDeviceCallbackMethodError verifies that unsupported methods
191+
// return safe error messages.
192+
func TestDeviceCallbackMethodError(t *testing.T) {
193+
httpServer, s := newTestServer(t, nil)
194+
defer httpServer.Close()
195+
196+
// Test with unsupported method
197+
rr := httptest.NewRecorder()
198+
req := httptest.NewRequest("PUT", "/device/callback", nil)
199+
s.ServeHTTP(rr, req)
200+
201+
resp := rr.Result()
202+
defer resp.Body.Close()
203+
204+
body, _ := io.ReadAll(resp.Body)
205+
bodyStr := string(body)
206+
207+
// Should not expose the method name in error
208+
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
209+
require.NotContains(t, bodyStr, "PUT")
210+
require.NotContains(t, bodyStr, "method not implemented")
211+
}
212+
213+
// TestRenderErrorSafeMessages tests that renderError uses safe messages
214+
func TestRenderErrorSafeMessages(t *testing.T) {
215+
tests := []struct {
216+
name string
217+
statusCode int
218+
message string
219+
expectedInBody []string
220+
notInBody []string
221+
}{
222+
{
223+
name: "Login error message",
224+
statusCode: http.StatusInternalServerError,
225+
message: ErrMsgLoginError,
226+
expectedInBody: []string{"Login error", "administrator"},
227+
notInBody: []string{"stack", "panic", ".go:"},
228+
},
229+
{
230+
name: "Authentication failed message",
231+
statusCode: http.StatusInternalServerError,
232+
message: ErrMsgAuthenticationFailed,
233+
expectedInBody: []string{"Authentication failed", "administrator"},
234+
notInBody: []string{"stack", "panic", ".go:"},
235+
},
236+
{
237+
name: "Database error message",
238+
statusCode: http.StatusInternalServerError,
239+
message: ErrMsgDatabaseError,
240+
expectedInBody: []string{"database error"},
241+
notInBody: []string{"sql:", "connection", "timeout"},
242+
},
243+
}
244+
245+
for _, tc := range tests {
246+
t.Run(tc.name, func(t *testing.T) {
247+
httpServer, s := newTestServer(t, nil)
248+
defer httpServer.Close()
249+
250+
rr := httptest.NewRecorder()
251+
req := httptest.NewRequest("GET", "/", nil)
252+
253+
s.renderError(req, rr, tc.statusCode, tc.message)
254+
255+
resp := rr.Result()
256+
defer resp.Body.Close()
257+
258+
body, _ := io.ReadAll(resp.Body)
259+
bodyStr := string(body)
260+
261+
require.Equal(t, tc.statusCode, resp.StatusCode)
262+
263+
for _, expected := range tc.expectedInBody {
264+
require.Contains(t, bodyStr, expected,
265+
"Response should contain: %s", expected)
266+
}
267+
268+
for _, notExpected := range tc.notInBody {
269+
require.NotContains(t, bodyStr, notExpected,
270+
"Response should not contain: %s", notExpected)
271+
}
272+
})
273+
}
274+
}
275+
276+
// TestTokenErrorDoesNotLeakDetails tests that token errors don't leak internal details
277+
func TestTokenErrorDoesNotLeakDetails(t *testing.T) {
278+
httpServer, s := newTestServer(t, nil)
279+
defer httpServer.Close()
280+
281+
// Create a token request with invalid credentials
282+
body := bytes.NewBufferString("grant_type=authorization_code&code=invalid_code")
283+
req := httptest.NewRequest("POST", "/token", body)
284+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
285+
req.SetBasicAuth("invalid_client", "invalid_secret")
286+
287+
rr := httptest.NewRecorder()
288+
s.ServeHTTP(rr, req)
289+
290+
resp := rr.Result()
291+
defer resp.Body.Close()
292+
293+
respBody, _ := io.ReadAll(resp.Body)
294+
bodyStr := string(respBody)
295+
296+
// Should not contain internal error details
297+
require.NotContains(t, bodyStr, "storage")
298+
require.NotContains(t, bodyStr, "not found")
299+
require.NotContains(t, bodyStr, ".go:")
300+
}

0 commit comments

Comments
 (0)