From 7066e2ffee7509b9a17eb899e75a1148454c4f59 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sat, 3 Oct 2026 08:32:17 -0700 Subject: [PATCH] Close the database wizard's endpoints once the site is installed POST /wizard_db and POST /test_db are registered for the life of the process and take no credentials, because during an install there is nobody to hold any. Nothing turned them off afterwards. Refuse both once the site has a database and an admin. Co-Authored-By: Claude Fable 5.1 --- goblog.go | 16 ++++++- wizard_install_only_test.go | 96 +++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 wizard_install_only_test.go diff --git a/goblog.go b/goblog.go index b90341a..2b8d834 100644 --- a/goblog.go +++ b/goblog.go @@ -451,8 +451,8 @@ func main() { // inline script, where the redirect_uri escaping was wrong (#631). router.GET("/login/github", goblog._blog.GithubLogin) router.GET("/wizard", goblog._wizard.SaveToken) - router.POST("/wizard_db", updateDB) - router.POST("/test_db", testDB) + router.POST("/wizard_db", goblog.installOnly, updateDB) + router.POST("/test_db", goblog.installOnly, testDB) router.POST("/api/v1/upload", goblog._admin.UploadFile) router.POST("/api/v1/preview", goblog._admin.Preview) router.PATCH("/api/v1/settings", goblog._admin.UpdateSettings) @@ -641,6 +641,18 @@ func requireJSON() gin.HandlerFunc { } } +// installOnly refuses the database wizard's endpoints once the site is +// installed, that is, once it has a database and an admin. They take no +// credentials because during an install there is nobody to hold any, so +// without this they stay open for good: /wizard_db rewrites .env with +// whatever database the request names, and /test_db opens any file or host +// it is given. +func (g *goblog) installOnly(c *gin.Context) { + if !g._wizard.IsDbNil() && !g._auth.IsWizardMode(c) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "This site is already installed"}) + } +} + // parse the form which should have passed the db info // and then create the env file with it func updateDB(c *gin.Context) { diff --git a/wizard_install_only_test.go b/wizard_install_only_test.go new file mode 100644 index 0000000..634c3f2 --- /dev/null +++ b/wizard_install_only_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "goblog/auth" + "goblog/tools" + "goblog/wizard" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// TestInstallOnly: the database wizard's endpoints are for installing. On a +// site that has a database and an admin, a request to them must change +// nothing; before that (no database yet, or a database with no admin) the +// wizard still has to work. +func TestInstallOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + post := func(g *goblog, path string) *httptest.ResponseRecorder { + router := gin.New() + router.SetHTMLTemplate(templateWithErrors(t)) + router.POST("/wizard_db", g.installOnly, updateDB) + router.POST("/test_db", g.installOnly, testDB) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("dbtype=sqlite&sqlite_file=other.db")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w + } + app := func(db *gorm.DB) *goblog { + a := auth.New(db, "test") + wz := wizard.New(db, "test") + return &goblog{_auth: &a, _wizard: &wz} + } + openDB := func() *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true}) + if err != nil { + t.Fatal(err) + } + if err := tools.Migrate(db); err != nil { + t.Fatal(err) + } + return db + } + + t.Run("installed site", func(t *testing.T) { + t.Chdir(t.TempDir()) + const env = "database=sqlite\nsqlite_db=live.db\nclient_id=x\nclient_secret=y\n" + if err := os.WriteFile(".env", []byte(env), 0600); err != nil { + t.Fatal(err) + } + db := openDB() + user := auth.BlogUser{Provider: auth.ProviderGitHub, ProviderID: "1", Login: "admin"} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&auth.AdminUser{BlogUserID: user.ID}).Error; err != nil { + t.Fatal(err) + } + g := app(db) + for _, path := range []string{"/wizard_db", "/test_db"} { + if w := post(g, path); w.Code != http.StatusForbidden { + t.Errorf("POST %s on an installed site: status %d, want 403", path, w.Code) + } + } + if got, _ := os.ReadFile(".env"); string(got) != env { + t.Errorf(".env was rewritten on an installed site:\n%s", got) + } + if _, err := os.Stat("other.db"); err == nil { + t.Error("/test_db created the file it was asked to open") + } + }) + + t.Run("no database yet", func(t *testing.T) { + t.Chdir(t.TempDir()) + if w := post(app(nil), "/wizard_db"); w.Code != http.StatusSeeOther { + t.Errorf("POST /wizard_db before install: status %d, want 303", w.Code) + } + if got, _ := os.ReadFile(".env"); !strings.Contains(string(got), "sqlite_db=other.db") { + t.Errorf(".env not written by the wizard: %q", got) + } + }) + + t.Run("database but no admin yet", func(t *testing.T) { + t.Chdir(t.TempDir()) + if w := post(app(openDB()), "/wizard_db"); w.Code != http.StatusSeeOther { + t.Errorf("POST /wizard_db mid-install: status %d, want 303", w.Code) + } + }) +}