Skip to content

Commit f6a8a5f

Browse files
authored
fix(site): prevent ExtractAPIKey from dirtying the HTML output (#8450)
If `httpmw.ExtractAPIKey` fails when we are rendering an HTML page, the HTML output will be dirtied with the error repsonse and the HTTP status will also be wrong. The use of this function in the `renderHTMLWithState` is additive, and failure means we simply can't embed static data. To fix this, we can simply pass a `http.ResponseWriter` that is no-op. Fixes #8351
1 parent e9d7a23 commit f6a8a5f

File tree

2 files changed

+69
-4
lines changed

2 files changed

+69
-4
lines changed

site/site.go

+15-4
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ func ShouldCacheFile(reqFile string) bool {
265265
}
266266

267267
func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
268-
if data, err := h.renderHTMLWithState(resp, request, reqPath, state); err == nil {
268+
if data, err := h.renderHTMLWithState(request, reqPath, state); err == nil {
269269
if reqPath == "" {
270270
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
271271
reqPath = "index.html"
@@ -278,7 +278,7 @@ func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, req
278278

279279
// renderWithState will render the file using the given nonce if the file exists
280280
// as a template. If it does not, it will return an error.
281-
func (h *Handler) renderHTMLWithState(rw http.ResponseWriter, r *http.Request, filePath string, state htmlState) ([]byte, error) {
281+
func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state htmlState) ([]byte, error) {
282282
var buf bytes.Buffer
283283
if filePath == "" {
284284
filePath = "index.html"
@@ -290,7 +290,11 @@ func (h *Handler) renderHTMLWithState(rw http.ResponseWriter, r *http.Request, f
290290

291291
// Cookies are sent when requesting HTML, so we can get the user
292292
// and pre-populate the state for the frontend to reduce requests.
293-
apiKey, actor, _ := httpmw.ExtractAPIKey(rw, r, httpmw.ExtractAPIKeyConfig{
293+
// We use a noop response writer because we don't want to write
294+
// anything to the response and break the HTML, an error means we
295+
// simply don't pre-populate the state.
296+
noopRW := noopResponseWriter{}
297+
apiKey, actor, ok := httpmw.ExtractAPIKey(noopRW, r, httpmw.ExtractAPIKeyConfig{
294298
Optional: true,
295299
DB: h.opts.Database,
296300
OAuth2Configs: h.opts.OAuth2Configs,
@@ -300,7 +304,7 @@ func (h *Handler) renderHTMLWithState(rw http.ResponseWriter, r *http.Request, f
300304
RedirectToLogin: false,
301305
SessionTokenFunc: nil,
302306
})
303-
if apiKey != nil && actor != nil {
307+
if ok && apiKey != nil && actor != nil {
304308
ctx := dbauthz.As(r.Context(), actor.Actor)
305309

306310
var eg errgroup.Group
@@ -392,6 +396,13 @@ func (h *Handler) renderHTMLWithState(rw http.ResponseWriter, r *http.Request, f
392396
return buf.Bytes(), nil
393397
}
394398

399+
// noopResponseWriter is a response writer that does nothing.
400+
type noopResponseWriter struct{}
401+
402+
func (noopResponseWriter) Header() http.Header { return http.Header{} }
403+
func (noopResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
404+
func (noopResponseWriter) WriteHeader(int) {}
405+
395406
// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
396407
// It adds various headers to enforce browser security features.
397408
func secureHeaders() *secure.Secure {

site/site_test.go

+54
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/coder/coder/coderd/database/db2sdk"
2727
"github.com/coder/coder/coderd/database/dbfake"
2828
"github.com/coder/coder/coderd/database/dbgen"
29+
"github.com/coder/coder/coderd/httpmw"
2930
"github.com/coder/coder/codersdk"
3031
"github.com/coder/coder/site"
3132
"github.com/coder/coder/testutil"
@@ -69,6 +70,59 @@ func TestInjection(t *testing.T) {
6970
require.Equal(t, db2sdk.User(user, []uuid.UUID{}), got)
7071
}
7172

73+
func TestInjectionFailureProducesCleanHTML(t *testing.T) {
74+
t.Parallel()
75+
76+
db := dbfake.New()
77+
78+
// Create an expired user with a refresh token, but provide no OAuth2
79+
// configuration so that refresh is impossible, this should result in
80+
// an error when httpmw.ExtractAPIKey is called.
81+
user := dbgen.User(t, db, database.User{})
82+
_, token := dbgen.APIKey(t, db, database.APIKey{
83+
UserID: user.ID,
84+
LastUsed: database.Now().Add(-time.Hour),
85+
ExpiresAt: database.Now().Add(-time.Second),
86+
LoginType: database.LoginTypeGithub,
87+
})
88+
_ = dbgen.UserLink(t, db, database.UserLink{
89+
UserID: user.ID,
90+
LoginType: database.LoginTypeGithub,
91+
OAuthRefreshToken: "hello",
92+
OAuthExpiry: database.Now().Add(-time.Second),
93+
})
94+
95+
binFs := http.FS(fstest.MapFS{})
96+
siteFS := fstest.MapFS{
97+
"index.html": &fstest.MapFile{
98+
Data: []byte("<html>{{ .User }}</html>"),
99+
},
100+
}
101+
handler := site.New(&site.Options{
102+
BinFS: binFs,
103+
Database: db,
104+
SiteFS: siteFS,
105+
106+
// No OAuth2 configs, refresh will fail.
107+
OAuth2Configs: &httpmw.OAuth2Configs{
108+
Github: nil,
109+
OIDC: nil,
110+
},
111+
})
112+
113+
r := httptest.NewRequest("GET", "/", nil)
114+
r.Header.Set(codersdk.SessionTokenHeader, token)
115+
rw := httptest.NewRecorder()
116+
117+
handler.ServeHTTP(rw, r)
118+
119+
// Ensure we get a clean HTML response with no user data or errors
120+
// from httpmw.ExtractAPIKey.
121+
assert.Equal(t, http.StatusOK, rw.Code)
122+
body := rw.Body.String()
123+
assert.Equal(t, "<html></html>", body)
124+
}
125+
72126
func TestCaching(t *testing.T) {
73127
t.Parallel()
74128

0 commit comments

Comments
 (0)