Files
godoxy-yusing/internal/api/v1/file/get_test.go
yusing 41d0d28ca8 fix(api): confine file edits to rooted config paths and restrict unauthenticated local API binds
Finish the file API traversal fix by rooting both GET and SET operations at the
actual file-type directory instead of the process working directory. This blocks
`..` escapes from `config/` and `config/middlewares/` while preserving valid
in-root reads and writes.

Also harden the optional unauthenticated local API listener so it only starts on
loopback addresses (`localhost`, `127.0.0.1`, `::1`). This preserves same-host
automation while preventing accidental exposure on wildcard, LAN, bridge, or
public interfaces.

Add regression tests for blocked traversal on GET and SET, valid in-root writes,
and loopback-only local API address validation. Fix an unrelated config test
cleanup panic so the touched package verification can run cleanly.

Constraint: `GODOXY_LOCAL_API_ADDR` is documented for local automation and must remain usable without adding a new auth flow

Constraint: File API behavior must keep valid config/provider/middleware edits working while blocking path escapes

Rejected: Mirror the previous GET `OpenInRoot(".", ...)` approach in SET | still allows escapes from `config/` to sibling paths under the working directory

Rejected: Keep unauthenticated non-loopback local API binds and document the risk | preserves a high-severity pre-auth network exposure

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Treat `LOCAL_API_ADDR` as same-host only; if non-loopback unauthenticated access is ever needed, gate it behind a separately named explicit insecure opt-in

Tested: `go test -count=1 -ldflags='-checklinkname=0' ./internal/api/v1/file -run 'Test(Get|Set)_PathTraversalBlocked' -v`

Tested: `go test -count=1 -ldflags='-checklinkname=0' ./internal/config -run '^TestValidateLocalAPIAddr$|^TestRouteValidateInboundMTLSProfile$' -v`

Tested: `go test -count=1 -ldflags='-checklinkname=0' ./internal/api/... ./internal/config/...`

Not-tested: End-to-end runtime verification of fsnotify reload behavior after a valid in-root provider edit
2026-04-09 16:44:01 +08:00

102 lines
2.4 KiB
Go

package fileapi_test
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
api "github.com/yusing/godoxy/internal/api"
fileapi "github.com/yusing/godoxy/internal/api/v1/file"
)
func setupFileAPITestRoot(t *testing.T) string {
t.Helper()
oldWD, err := os.Getwd()
require.NoError(t, err)
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "config", "middlewares"), 0o755))
require.NoError(t, os.Chdir(root))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWD))
})
return root
}
func newFileContentRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(api.ErrorHandler())
r.GET("/api/v1/file/content", fileapi.Get)
r.PUT("/api/v1/file/content", fileapi.Set)
return r
}
func TestGet_PathTraversalBlocked(t *testing.T) {
root := setupFileAPITestRoot(t)
const (
insideFilename = "providers.yml"
insideContent = "app: inside\n"
outsideContent = "app: outside\n"
)
require.NoError(t, os.WriteFile(filepath.Join(root, "config", insideFilename), []byte(insideContent), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(root, "secret.yml"), []byte(outsideContent), 0o644))
r := newFileContentRouter()
t.Run("read_in_root_file", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/file/content?type=config&filename="+insideFilename, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, insideContent, w.Body.String())
})
tests := []struct {
name string
filename string
queryEscaped bool
}{
{
name: "dotdot_traversal_to_sibling_file",
filename: "../secret.yml",
},
{
name: "url_encoded_dotdot_traversal_to_sibling_file",
filename: "../secret.yml",
queryEscaped: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filename := tt.filename
if tt.queryEscaped {
filename = url.QueryEscape(filename)
}
url := "/api/v1/file/content?type=config&filename=" + filename
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// "Blocked" means we should never successfully read the outside file.
assert.NotEqual(t, http.StatusOK, w.Code)
assert.NotEqual(t, outsideContent, w.Body.String())
})
}
}