Skip to content

Commit 2dea623

Browse files
authored
1 parent f3ef599 commit 2dea623

4 files changed

Lines changed: 224 additions & 0 deletions

File tree

httpserver/security_bypass_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,108 @@ func TestUpload_ValidFilename_Succeeds(t *testing.T) {
134134
require.NoError(t, err)
135135
require.Equal(t, "hello", string(got))
136136
}
137+
138+
// GHSA-966r-mw4j-rv64: HTTP PUT opens the target with O_TRUNC, so overwriting an
139+
// existing file destroys its contents. Under --no-delete that must be blocked and
140+
// the file left intact.
141+
func TestPut_NoDelete_BlocksOverwrite(t *testing.T) {
142+
webroot := t.TempDir()
143+
fs, cleanup := newTestFileServer(t, webroot)
144+
defer cleanup()
145+
fs.NoDelete = true
146+
require.NoError(t, os.WriteFile(filepath.Join(webroot, "victim.txt"), []byte("VICTIM"), 0644))
147+
148+
r := httptest.NewRequest(http.MethodPut, "/victim.txt", bytes.NewBufferString("CLOBBERED"))
149+
r.Header.Set("X-CSRF-Token", "test-csrf")
150+
w := httptest.NewRecorder()
151+
152+
fs.put(w, r)
153+
154+
require.Equal(t, http.StatusForbidden, w.Code)
155+
got, err := os.ReadFile(filepath.Join(webroot, "victim.txt"))
156+
require.NoError(t, err)
157+
require.Equal(t, "VICTIM", string(got), "existing file must not be truncated/overwritten")
158+
}
159+
160+
// --upload-only likewise forbids destroying existing content via PUT overwrite.
161+
func TestPut_UploadOnly_BlocksOverwrite(t *testing.T) {
162+
webroot := t.TempDir()
163+
fs, cleanup := newTestFileServer(t, webroot)
164+
defer cleanup()
165+
fs.UploadOnly = true
166+
require.NoError(t, os.WriteFile(filepath.Join(webroot, "victim.txt"), []byte("VICTIM"), 0644))
167+
168+
r := httptest.NewRequest(http.MethodPut, "/victim.txt", bytes.NewBufferString("CLOBBERED"))
169+
r.Header.Set("X-CSRF-Token", "test-csrf")
170+
w := httptest.NewRecorder()
171+
172+
fs.put(w, r)
173+
174+
require.Equal(t, http.StatusForbidden, w.Code)
175+
got, err := os.ReadFile(filepath.Join(webroot, "victim.txt"))
176+
require.NoError(t, err)
177+
require.Equal(t, "VICTIM", string(got))
178+
}
179+
180+
// Creating a new file via PUT destroys nothing and must stay allowed under
181+
// --no-delete. Confirms the overwrite guard does not over-block fresh writes.
182+
func TestPut_NoDelete_AllowsNewFile(t *testing.T) {
183+
webroot := t.TempDir()
184+
fs, cleanup := newTestFileServer(t, webroot)
185+
defer cleanup()
186+
fs.NoDelete = true
187+
188+
r := httptest.NewRequest(http.MethodPut, "/fresh.txt", bytes.NewBufferString("NEW"))
189+
r.Header.Set("X-CSRF-Token", "test-csrf")
190+
w := httptest.NewRecorder()
191+
192+
fs.put(w, r)
193+
194+
got, err := os.ReadFile(filepath.Join(webroot, "fresh.txt"))
195+
require.NoError(t, err)
196+
require.Equal(t, "NEW", string(got))
197+
}
198+
199+
// The multipart upload path renames the temp file over any existing same-named
200+
// file, clobbering it. Under --no-delete that overwrite must be skipped and the
201+
// existing file left intact.
202+
func TestUpload_NoDelete_BlocksOverwrite(t *testing.T) {
203+
webroot := t.TempDir()
204+
fs, cleanup := newTestFileServer(t, webroot)
205+
defer cleanup()
206+
fs.NoDelete = true
207+
require.NoError(t, os.WriteFile(filepath.Join(webroot, "victim.txt"), []byte("VICTIM"), 0644))
208+
209+
body, ctype := multipartUpload(t, "victim.txt", "CLOBBERED")
210+
r := httptest.NewRequest(http.MethodPost, "/upload", body)
211+
r.Header.Set("Content-Type", ctype)
212+
r.Header.Set("X-CSRF-Token", "test-csrf")
213+
w := httptest.NewRecorder()
214+
215+
fs.upload(w, r)
216+
217+
got, err := os.ReadFile(filepath.Join(webroot, "victim.txt"))
218+
require.NoError(t, err)
219+
require.Equal(t, "VICTIM", string(got), "existing file must not be clobbered by upload")
220+
}
221+
222+
// A new-named upload must still succeed under --no-delete.
223+
func TestUpload_NoDelete_AllowsNewFile(t *testing.T) {
224+
webroot := t.TempDir()
225+
fs, cleanup := newTestFileServer(t, webroot)
226+
defer cleanup()
227+
fs.NoDelete = true
228+
229+
body, ctype := multipartUpload(t, "fresh.txt", "NEW")
230+
r := httptest.NewRequest(http.MethodPost, "/upload", body)
231+
r.Header.Set("Content-Type", ctype)
232+
r.Header.Set("X-CSRF-Token", "test-csrf")
233+
w := httptest.NewRecorder()
234+
235+
fs.upload(w, r)
236+
237+
require.Equal(t, http.StatusSeeOther, w.Code)
238+
got, err := os.ReadFile(filepath.Join(webroot, "fresh.txt"))
239+
require.NoError(t, err)
240+
require.Equal(t, "NEW", string(got))
241+
}

httpserver/updown.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ func (fs *FileServer) put(w http.ResponseWriter, req *http.Request) {
5252
return
5353
}
5454

55+
// Overwriting an existing file with O_TRUNC destroys its previous contents,
56+
// which is a deletion. Block it under --no-delete / --upload-only, mirroring
57+
// the WebDAV PUT/COPY overwrite guard. Creating a new file stays allowed.
58+
if fs.UploadOnly || fs.NoDelete {
59+
if info, statErr := os.Stat(savepath); statErr == nil && !info.IsDir() {
60+
fs.handleError(w, req, fmt.Errorf("%s", "Overwriting existing files is not allowed due to 'no delete' / 'upload only' option"), http.StatusForbidden)
61+
return
62+
}
63+
}
64+
5565
// Enforce .goshs ACL and upload size limit
5666
if !fs.prepareWrite(w, req, filepath.Dir(savepath)) {
5767
return
@@ -153,6 +163,16 @@ func (fs *FileServer) upload(w http.ResponseWriter, req *http.Request) {
153163
finalPath := filepath.Join(targetDir, filenameClean)
154164
tempPath := finalPath + "~"
155165

166+
// The final os.Rename clobbers any existing same-named file, destroying
167+
// its contents. Skip such overwrites under --no-delete / --upload-only,
168+
// matching the WebDAV PUT/COPY overwrite guard; new files still upload.
169+
if fs.UploadOnly || fs.NoDelete {
170+
if info, statErr := os.Stat(finalPath); statErr == nil && !info.IsDir() {
171+
logger.Warnf("blocked overwrite of existing file %q due to 'no delete' / 'upload only' option", finalPath)
172+
continue
173+
}
174+
}
175+
156176
// Defence in depth: ensure the resolved destination stays inside targetDir
157177
// even if filenameClean ever slips a separator or traversal past the checks
158178
// above.

httpserver/webdav_acl.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ func (fs *FileServer) webdavGuard(next http.Handler) http.HandlerFunc {
4848
http.Error(w, "read-only", http.StatusForbidden)
4949
return
5050
}
51+
// A PUT onto an existing file truncates/replaces its contents, which
52+
// is a destruction of the previous data — gate it exactly like the
53+
// overwriting COPY case. MKCOL only ever creates, so it is unaffected.
54+
if r.Method == http.MethodPut && (fs.UploadOnly || fs.NoDelete) && fs.webdavTargetExists(r) {
55+
http.Error(w, "overwrite disabled", http.StatusForbidden)
56+
return
57+
}
58+
case http.MethodPost:
59+
// golang.org/x/net/webdav routes POST to the same read handler as GET,
60+
// so an unguarded POST returns the full file body — bypassing the
61+
// GET/HEAD upload-only block below. WebDAV POST has no legitimate use
62+
// in this server, so reject it outright regardless of mode.
63+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
64+
return
5165
case "COPY":
5266
if fs.ReadOnly {
5367
http.Error(w, "read-only", http.StatusForbidden)
@@ -84,6 +98,19 @@ func (fs *FileServer) webdavGuard(next http.Handler) http.HandlerFunc {
8498
}
8599
}
86100

101+
// webdavTargetExists reports whether the request URL itself already points at an
102+
// existing (non-directory) resource inside the webroot. Used to distinguish an
103+
// overwriting PUT (which truncates/replaces existing content) from a PUT that
104+
// merely creates a new file.
105+
func (fs *FileServer) webdavTargetExists(r *http.Request) bool {
106+
abs, err := sanitizePath(fs.Webroot, r.URL.Path)
107+
if err != nil {
108+
return false
109+
}
110+
info, err := os.Stat(abs)
111+
return err == nil && !info.IsDir()
112+
}
113+
87114
// webdavDestExists reports whether the Destination header of a COPY/MOVE
88115
// request already points at an existing resource inside the webroot. It is used
89116
// to distinguish an overwriting COPY (which deletes the destination) from a

httpserver/webdav_modeflags_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,75 @@ func TestWebdav_Default_AllowsMove(t *testing.T) {
147147
require.True(t, fileMissing(t, dir, "secret.txt"), "MOVE should remove the source")
148148
require.Equal(t, "TOP-SECRET", fileContent(t, dir, "gone.txt"))
149149
}
150+
151+
// GHSA-rc9g-fmpg-c6pp: golang.org/x/net/webdav routes POST to the same read
152+
// handler as GET, so an unguarded POST returned the full file body, bypassing
153+
// the GET/HEAD --upload-only block. POST must be rejected outright and never
154+
// leak file contents — here under --upload-only.
155+
func TestWebdav_UploadOnly_PostDoesNotLeak(t *testing.T) {
156+
dir := webdavModeTree(t)
157+
fs, cleanup := newTestFileServer(t, dir)
158+
defer cleanup()
159+
fs.UploadOnly = true
160+
h := newWebdavTestHandler(fs)
161+
162+
w := davReq(t, h, "POST", "/secret.txt", "", "")
163+
require.Equal(t, http.StatusMethodNotAllowed, w.Code)
164+
require.NotContains(t, w.Body.String(), "TOP-SECRET", "POST must not leak file contents")
165+
}
166+
167+
// POST must be rejected even with no mode flags — it has no legitimate WebDAV
168+
// use here and would otherwise alias GET.
169+
func TestWebdav_Default_PostRejected(t *testing.T) {
170+
dir := webdavModeTree(t)
171+
fs, cleanup := newTestFileServer(t, dir)
172+
defer cleanup()
173+
h := newWebdavTestHandler(fs)
174+
175+
w := davReq(t, h, "POST", "/secret.txt", "", "")
176+
require.Equal(t, http.StatusMethodNotAllowed, w.Code)
177+
require.NotContains(t, w.Body.String(), "TOP-SECRET")
178+
}
179+
180+
// GHSA-966r-mw4j-rv64: a PUT onto an existing file truncates/replaces its
181+
// contents. Under --no-delete that overwrite must be blocked and the existing
182+
// file left intact.
183+
func TestWebdav_NoDelete_BlocksPutOverwrite(t *testing.T) {
184+
dir := webdavModeTree(t)
185+
fs, cleanup := newTestFileServer(t, dir)
186+
defer cleanup()
187+
fs.NoDelete = true
188+
h := newWebdavTestHandler(fs)
189+
190+
w := davReq(t, h, "PUT", "/victim.txt", "", "")
191+
require.Equal(t, http.StatusForbidden, w.Code)
192+
require.Equal(t, "VICTIM", fileContent(t, dir, "victim.txt"), "existing file must survive a blocked PUT")
193+
}
194+
195+
// --upload-only likewise forbids destroying existing content via PUT overwrite.
196+
func TestWebdav_UploadOnly_BlocksPutOverwrite(t *testing.T) {
197+
dir := webdavModeTree(t)
198+
fs, cleanup := newTestFileServer(t, dir)
199+
defer cleanup()
200+
fs.UploadOnly = true
201+
h := newWebdavTestHandler(fs)
202+
203+
w := davReq(t, h, "PUT", "/victim.txt", "", "")
204+
require.Equal(t, http.StatusForbidden, w.Code)
205+
require.Equal(t, "VICTIM", fileContent(t, dir, "victim.txt"))
206+
}
207+
208+
// A PUT that creates a brand-new file destroys nothing and must stay allowed
209+
// under --no-delete (upload-only permits uploads by definition). Confirms the
210+
// overwrite guard does not over-block fresh writes.
211+
func TestWebdav_NoDelete_AllowsPutNewFile(t *testing.T) {
212+
dir := webdavModeTree(t)
213+
fs, cleanup := newTestFileServer(t, dir)
214+
defer cleanup()
215+
fs.NoDelete = true
216+
h := newWebdavTestHandler(fs)
217+
218+
w := davReq(t, h, "PUT", "/fresh.txt", "", "")
219+
require.Less(t, w.Code, http.StatusBadRequest, "creating a new file must be allowed")
220+
require.False(t, fileMissing(t, dir, "fresh.txt"), "new file should have been created")
221+
}

0 commit comments

Comments
 (0)