Skip to content

Commit 2b2d3ae

Browse files
tae2089claude
andcommitted
Confine wiki doc API to the docs subtree, not the working directory
resolveDocPath listed "." among its roots, so /wiki/api/doc and /wiki/api/context could read any file under the process CWD (.env, go.mod, source) as a "doc". Resolve shared docs against the docs/ directory itself for both relative and absolute paths. Also drop the orphaned retrieveResult type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b332f51 commit 2b2d3ae

2 files changed

Lines changed: 42 additions & 9 deletions

File tree

internal/wikiserver/server.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -552,13 +552,6 @@ type contextResponse struct {
552552
Items []contextItem `json:"items"`
553553
}
554554

555-
// @intent return retrieval metadata and optional bounded Markdown content to the Wiki UI.
556-
type retrieveResult struct {
557-
ragindex.RetrieveResult
558-
Content string `json:"content,omitempty"`
559-
ContentTruncated bool `json:"content_truncated,omitempty"`
560-
}
561-
562555
// @intent return the resolved Wiki navigation target for one ccg:// ref.
563556
type refResponse struct {
564557
Namespace string `json:"namespace"`
@@ -723,6 +716,8 @@ func readDocFile(resolved string) (string, string, error) {
723716
}
724717

725718
// @intent resolve a generated doc path under approved docs, RAG, or namespace roots.
719+
// @domainRule the working directory is only searched through its docs/ subtree; arbitrary
720+
// repository files (config, source, secrets) must never be readable through the doc API.
726721
func (s *Server) resolveDocPath(namespace, docPath string) (string, error) {
727722
clean := filepath.Clean(docPath)
728723
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
@@ -732,7 +727,9 @@ func (s *Server) resolveDocPath(namespace, docPath string) (string, error) {
732727
if namespace != ctxns.DefaultNamespace {
733728
roots = append(roots, filepath.Join(s.namespaceRoot, namespace))
734729
}
735-
roots = append(roots, ".", s.ragIndexDir, s.namespaceRoot)
730+
// Generated shared docs live under ./docs (doc_path values carry the docs/ prefix),
731+
// so the local root is the docs directory itself, not the bare working directory.
732+
roots = append(roots, "docs", s.ragIndexDir, s.namespaceRoot)
736733
if filepath.IsAbs(clean) {
737734
for _, root := range roots {
738735
target, err := safeAbsolutePath(root, clean)
@@ -746,7 +743,17 @@ func (s *Server) resolveDocPath(namespace, docPath string) (string, error) {
746743
return "", fs.ErrNotExist
747744
}
748745
for _, root := range roots {
749-
target, err := safePath(root, clean)
746+
rel := clean
747+
if root == "docs" {
748+
// doc_path values are docs-prefixed ("docs/pkg/file.md"); resolve the remainder
749+
// inside the docs root so containment is enforced against docs/, not the CWD.
750+
after, ok := strings.CutPrefix(clean, "docs"+string(os.PathSeparator))
751+
if !ok {
752+
continue
753+
}
754+
rel = after
755+
}
756+
target, err := safePath(root, rel)
750757
if err != nil {
751758
continue
752759
}

internal/wikiserver/server_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,32 @@ func TestAPI_DocReturnsNotFoundWithoutDBFallback(t *testing.T) {
690690
}
691691
}
692692

693+
func TestAPI_DocRejectsNonDocsFilesUnderCwd(t *testing.T) {
694+
srv := newTestServer(t)
695+
// A sensitive non-docs file in the working directory must not be readable as a "doc".
696+
if err := os.WriteFile("secret.env", []byte("TOKEN=hunter2"), 0o644); err != nil {
697+
t.Fatalf("write secret: %v", err)
698+
}
699+
for _, p := range []string{"secret.env", "./secret.env"} {
700+
req := httptest.NewRequest(http.MethodGet, "/wiki/api/doc?namespace=default&path="+p, nil)
701+
rec := httptest.NewRecorder()
702+
srv.APIHandler().ServeHTTP(rec, req)
703+
if rec.Code == http.StatusOK {
704+
t.Fatalf("path %q: non-docs CWD file served as doc: %s", p, rec.Body.String())
705+
}
706+
}
707+
abs, err := filepath.Abs("secret.env")
708+
if err != nil {
709+
t.Fatal(err)
710+
}
711+
req := httptest.NewRequest(http.MethodGet, "/wiki/api/doc?namespace=default&path="+abs, nil)
712+
rec := httptest.NewRecorder()
713+
srv.APIHandler().ServeHTTP(rec, req)
714+
if rec.Code == http.StatusOK {
715+
t.Fatalf("absolute path: non-docs CWD file served as doc: %s", rec.Body.String())
716+
}
717+
}
718+
693719
func TestAPI_DocRejectsTraversal(t *testing.T) {
694720
srv := newTestServer(t)
695721
req := httptest.NewRequest(http.MethodGet, "/wiki/api/doc?namespace=default&path=../secret.md", nil)

0 commit comments

Comments
 (0)