agentbbs/internal/gopher/gopher_test.go
RissRIce c2a06f1cdf
Some checks failed
CI / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled
test / test (push) Has been cancelled
fix(gopher): block symlink escapes (#85)
Co-authored-by: rissrice2105-agent <rissrice2105-agent@users.noreply.github.com>
2026-07-10 02:33:34 -07:00

255 lines
8.4 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 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 TestSymlinkEscapeRefused(t *testing.T) {
srv, dataDir := newTestServer(t)
home := filepath.Join(dataDir, "users", "alice", "public_html")
link := filepath.Join(home, "leak.txt")
if err := os.Symlink(filepath.Join(dataDir, "secret.txt"), link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
r := srv.Resolve("/~alice/leak.txt", false, "")
if r.Kind != KindError {
t.Fatalf("symlink escape should be refused, got %v", r.Kind)
}
if strings.Contains(r.Text, "TOP SECRET") || strings.Contains(string(r.Data), "TOP SECRET") {
t.Fatalf("symlink escape leaked the secret file: %+v", r)
}
}
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())
}
}