mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
The userTree function confined selectors with path cleaning and HasPrefix checks, but called os.Stat/os.ReadFile on the unresolved path. A symlink created inside a member's public area pointing outside would pass the lexical checks and leak external content. Fix: resolve symlinks with filepath.EvalSymlinks after the path check and reject the request if the real path escapes the member's area. Adds TestSymlinkEscapeRefused to verify both rejection of external symlinks and acceptance of symlinks within the member area.
270 lines
9.1 KiB
Go
270 lines
9.1 KiB
Go
package gopher
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/profullstack/agentbbs/internal/store"
|
|
)
|
|
|
|
// fakeContent is an in-memory Content for the resolver tests (no real store).
|
|
type fakeContent struct {
|
|
users []store.User
|
|
groups []store.NewsGroup
|
|
articles map[string][]store.NewsArticle
|
|
}
|
|
|
|
func (f *fakeContent) ListUsers(int) ([]store.User, error) { return f.users, nil }
|
|
func (f *fakeContent) NewsGroups() ([]store.NewsGroup, error) { return f.groups, nil }
|
|
func (f *fakeContent) NewsArticlesRange(g string, from, to int64) ([]store.NewsArticle, error) {
|
|
var out []store.NewsArticle
|
|
for _, a := range f.articles[g] {
|
|
if a.Num >= from && a.Num <= to {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeContent) NewsArticleByNum(g string, num int64) (store.NewsArticle, bool, error) {
|
|
for _, a := range f.articles[g] {
|
|
if a.Num == num {
|
|
return a, true, nil
|
|
}
|
|
}
|
|
return store.NewsArticle{}, false, nil
|
|
}
|
|
|
|
// newTestServer wires a Server over a temp data dir with two members (one
|
|
// banned), two newsgroups (one public), and a homepage tree for alice.
|
|
func newTestServer(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
dataDir := t.TempDir()
|
|
// alice's homepage: index.html (text) + assets/logo.png (binary) + subdir.
|
|
home := filepath.Join(dataDir, "users", "alice", "public_html")
|
|
mustMkdir(t, filepath.Join(home, "assets"))
|
|
mustWrite(t, filepath.Join(home, "index.html"), "<h1>hi</h1>")
|
|
mustWrite(t, filepath.Join(home, "assets", "logo.png"), "\x89PNGbinary")
|
|
// a file OUTSIDE any member area, to prove traversal cannot reach it.
|
|
mustWrite(t, filepath.Join(dataDir, "secret.txt"), "TOP SECRET")
|
|
|
|
fc := &fakeContent{
|
|
users: []store.User{
|
|
{Name: "alice"},
|
|
{Name: "bob", Banned: true},
|
|
{Name: "carol"},
|
|
},
|
|
groups: []store.NewsGroup{
|
|
{Name: "pfs.announce", Description: "Announcements", High: 1},
|
|
{Name: "pfs.secret", Description: "Members only", High: 1},
|
|
},
|
|
articles: map[string][]store.NewsArticle{
|
|
"pfs.announce": {{Group: "pfs.announce", Num: 1, Subject: "Welcome", From: "sysop", Body: "hello\n.\nworld"}},
|
|
"pfs.secret": {{Group: "pfs.secret", Num: 1, Subject: "Hush", From: "sysop", Body: "shh"}},
|
|
},
|
|
}
|
|
srv := New(fc, dataDir, "gopher.test", "70", []string{"pfs.announce"},
|
|
func() string { return "About body here" })
|
|
return srv, dataDir
|
|
}
|
|
|
|
func mustMkdir(t *testing.T, p string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func mustWrite(t *testing.T, p, s string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMenuWireFormat(t *testing.T) {
|
|
m := NewMenu("h.test", "70")
|
|
m.Info("hi").Link(TypeMenu, "Members", "/members")
|
|
got := m.Render()
|
|
// Info row is type 'i'; the link row is tab-delimited with host/port; the
|
|
// whole thing ends in the RFC 1436 dot line.
|
|
if !strings.HasPrefix(got, "ihi\t") {
|
|
t.Errorf("info row missing/malformed: %q", got)
|
|
}
|
|
if !strings.Contains(got, "1Members\t/members\th.test\t70\r\n") {
|
|
t.Errorf("link row malformed: %q", got)
|
|
}
|
|
if !strings.HasSuffix(got, ".\r\n") {
|
|
t.Errorf("menu must end with the dot terminator: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveRootBanner(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
pub := srv.Resolve("", false, "")
|
|
if pub.Kind != KindMenu {
|
|
t.Fatalf("root should be a menu, got %v", pub.Kind)
|
|
}
|
|
wire := string(pub.Wire())
|
|
for _, want := range []string{"/about", "/members", "/news", "/files", "ssh gopher@gopher.test"} {
|
|
if !strings.Contains(wire, want) {
|
|
t.Errorf("public root missing %q\n%s", want, wire)
|
|
}
|
|
}
|
|
authed := string(srv.Resolve("/", true, "alice").Wire())
|
|
if !strings.Contains(authed, "Signed in as alice") {
|
|
t.Errorf("authed root should greet the member:\n%s", authed)
|
|
}
|
|
}
|
|
|
|
func TestResolveAbout(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
r := srv.Resolve("/about", false, "")
|
|
if r.Kind != KindText || !strings.Contains(r.Text, "About body here") {
|
|
t.Fatalf("about wrong: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestResolveMembersExcludesBanned(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
wire := string(srv.Resolve("/members", false, "").Wire())
|
|
if !strings.Contains(wire, "\t/~alice\t") || !strings.Contains(wire, "\t/~carol\t") {
|
|
t.Errorf("members should list alice and carol:\n%s", wire)
|
|
}
|
|
if strings.Contains(wire, "/~bob") {
|
|
t.Errorf("banned member bob must not appear:\n%s", wire)
|
|
}
|
|
}
|
|
|
|
func TestHomepageFileAndDir(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
// directory listing
|
|
dir := srv.Resolve("/~alice", false, "")
|
|
if dir.Kind != KindMenu {
|
|
t.Fatalf("homepage root should be a dir menu, got %v", dir.Kind)
|
|
}
|
|
dw := string(dir.Wire())
|
|
if !strings.Contains(dw, "/~alice/index.html") || !strings.Contains(dw, "/~alice/assets") {
|
|
t.Errorf("dir listing missing entries:\n%s", dw)
|
|
}
|
|
// text file
|
|
txt := srv.Resolve("/~alice/index.html", false, "")
|
|
if txt.Kind != KindText || !strings.Contains(txt.Text, "<h1>hi</h1>") {
|
|
t.Errorf("index.html should be served as text: %+v", txt)
|
|
}
|
|
// binary file
|
|
bin := srv.Resolve("/~alice/assets/logo.png", false, "")
|
|
if bin.Kind != KindBinary || string(bin.Data) != "\x89PNGbinary" {
|
|
t.Errorf("png should be binary: %+v", bin)
|
|
}
|
|
}
|
|
|
|
func TestSymlinkEscapeRefused(t *testing.T) {
|
|
srv, dataDir := newTestServer(t)
|
|
// Create a symlink inside alice's public_html that points to secret.txt
|
|
// outside the member area. The selector should be refused.
|
|
link := filepath.Join(dataDir, "users", "alice", "public_html", "leak.txt")
|
|
if err := os.Symlink(filepath.Join(dataDir, "secret.txt"), link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := srv.Resolve("/~alice/leak.txt", false, "")
|
|
if r.Kind == KindText && strings.Contains(r.Text, "TOP SECRET") {
|
|
t.Fatal("symlink escape: resolved path pointed outside member area but content was served")
|
|
}
|
|
// Symlink to a subdirectory should still work.
|
|
subdir := filepath.Join(dataDir, "users", "alice", "public_html", "sub")
|
|
if err := os.Mkdir(subdir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inner := filepath.Join(subdir, "hello.txt")
|
|
if err := os.WriteFile(inner, []byte("inside"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Symlink inside the area pointing to another path within the area is fine.
|
|
sym := filepath.Join(dataDir, "users", "alice", "public_html", "ok-link")
|
|
if err := os.Symlink(inner, sym); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r2 := srv.Resolve("/~alice/ok-link", false, "")
|
|
if r2.Kind != KindText || !strings.Contains(r2.Text, "inside") {
|
|
t.Fatalf("symlink within area should be allowed: got %+v", r2)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalRefused(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
for _, sel := range []string{
|
|
"/~alice/../../secret.txt",
|
|
"/~alice/../../../etc/passwd",
|
|
"/files/~alice/../../secret.txt",
|
|
"/~alice/assets/../../../secret.txt",
|
|
} {
|
|
r := srv.Resolve(sel, true, "alice")
|
|
if r.Kind == KindText && strings.Contains(r.Text, "TOP SECRET") {
|
|
t.Fatalf("traversal %q leaked the secret file", sel)
|
|
}
|
|
if r.Kind == KindBinary && strings.Contains(string(r.Data), "TOP SECRET") {
|
|
t.Fatalf("traversal %q leaked the secret file (binary)", sel)
|
|
}
|
|
}
|
|
// A bad member name is refused outright.
|
|
if r := srv.Resolve("/~..", false, ""); r.Kind != KindError {
|
|
t.Errorf("bad member name should error, got %v", r.Kind)
|
|
}
|
|
}
|
|
|
|
func TestNewsPublicVsAuthed(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
|
|
// Public group list shows only the allowlisted group.
|
|
pub := string(srv.Resolve("/news", false, "").Wire())
|
|
if !strings.Contains(pub, "/news/pfs.announce") {
|
|
t.Errorf("public news should list pfs.announce:\n%s", pub)
|
|
}
|
|
if strings.Contains(pub, "/news/pfs.secret") {
|
|
t.Errorf("public news must hide pfs.secret:\n%s", pub)
|
|
}
|
|
|
|
// Authed group list shows every group.
|
|
authed := string(srv.Resolve("/news", true, "alice").Wire())
|
|
if !strings.Contains(authed, "/news/pfs.secret") {
|
|
t.Errorf("authed news should list pfs.secret:\n%s", authed)
|
|
}
|
|
|
|
// Public access to a members-only group is refused...
|
|
if r := srv.Resolve("/news/pfs.secret", false, ""); r.Kind != KindError {
|
|
t.Errorf("public access to members-only group should error, got %v", r.Kind)
|
|
}
|
|
// ...but the authenticated surface can read it.
|
|
if r := srv.Resolve("/news/pfs.secret/1", true, "alice"); r.Kind != KindText || !strings.Contains(r.Text, "shh") {
|
|
t.Errorf("authed should read the private article: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestArticleDotStuffing(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
r := srv.Resolve("/news/pfs.announce/1", false, "")
|
|
if r.Kind != KindText {
|
|
t.Fatalf("article should be text, got %v", r.Kind)
|
|
}
|
|
// The body has a lone "." line; on the wire it must be escaped to ".." so it
|
|
// is not read as the terminator, and the real terminator is the final line.
|
|
wire := string(r.Wire())
|
|
if !strings.Contains(wire, "\r\n..\r\n") {
|
|
t.Errorf("lone dot line should be dot-stuffed to '..':\n%q", wire)
|
|
}
|
|
if !strings.HasSuffix(wire, "\r\n.\r\n") {
|
|
t.Errorf("text response must end with the dot terminator:\n%q", wire)
|
|
}
|
|
}
|
|
|
|
func TestUnknownSelector(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
r := srv.Resolve("/does/not/exist", false, "")
|
|
if r.Kind != KindError {
|
|
t.Fatalf("unknown selector should error, got %v", r.Kind)
|
|
}
|
|
if !strings.HasPrefix(string(r.Wire()), "3") {
|
|
t.Errorf("error response should be a type-3 line: %q", r.Wire())
|
|
}
|
|
}
|