From 0ad87448cd0415af5cef2e5a52269d4b4627e570 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Date: Fri, 28 Aug 2026 00:14:25 -0600 Subject: [PATCH 1/2] fix(auth): harden first-user with single-user guard, rate limit, noindex --- ...054515_add_users_single_user_invariant.sql | 16 ++++++++++++++ internal/view/middleware/no_index.go | 14 +++++++++++++ internal/view/router.go | 3 +++ internal/view/web/auth/create_first_user.go | 21 ++++++++++++++++++- internal/view/web/auth/router.go | 5 ++++- internal/view/web/layout/common.go | 1 + 6 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 internal/database/migrations/20260828054515_add_users_single_user_invariant.sql create mode 100644 internal/view/middleware/no_index.go diff --git a/internal/database/migrations/20260828054515_add_users_single_user_invariant.sql b/internal/database/migrations/20260828054515_add_users_single_user_invariant.sql new file mode 100644 index 00000000..17ae2a17 --- /dev/null +++ b/internal/database/migrations/20260828054515_add_users_single_user_invariant.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +DO $$ +BEGIN + IF (SELECT COUNT(*) FROM users) > 1 THEN + RAISE EXCEPTION E'CRITICAL SECURITY ALERT: PG Back Web found more than one user in the database.\n\nThis instance was probably compromised through the create-first-user endpoint vulnerability, fixed in v0.5.2. PG Back Web refuses to start until the database is cleaned.\n\nTo fix this:\n\n1. List all users to identify the legitimate one:\n\n SELECT id, name, email, created_at FROM users ORDER BY created_at;\n\n2. Delete every user except the legitimate one (replace the email below with yours):\n\n DELETE FROM users WHERE email NOT IN (''your-email@example.com'');\n\n3. Rotate ALL credentials stored in PG Back Web: PostgreSQL connection strings and S3 destination keys.\n\n4. Start PG Back Web again.\n\nDeleting a user automatically deletes their sessions too.'; + END IF; +END $$; + +CREATE UNIQUE INDEX users_single_user_invariant_idx ON users ((true)); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS users_single_user_invariant_idx; +-- +goose StatementEnd diff --git a/internal/view/middleware/no_index.go b/internal/view/middleware/no_index.go new file mode 100644 index 00000000..085edbf0 --- /dev/null +++ b/internal/view/middleware/no_index.go @@ -0,0 +1,14 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" +) + +// NoIndex sets the X-Robots-Tag header on every response to prevent +// search engines from indexing the application pages. +func (m *Middleware) NoIndex(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Response().Header().Set("X-Robots-Tag", "noindex, nofollow") + return next(c) + } +} diff --git a/internal/view/router.go b/internal/view/router.go index 9af5d15a..24939ee5 100644 --- a/internal/view/router.go +++ b/internal/view/router.go @@ -17,6 +17,9 @@ import ( func MountRouter(app *echo.Echo, servs *service.Service) { mids := middleware.New(servs) + // Register global middlewares + app.Use(mids.NoIndex) + // Create the base group with the path prefix (if any) baseGroup := app.Group(pathutil.GetPathPrefix()) diff --git a/internal/view/web/auth/create_first_user.go b/internal/view/web/auth/create_first_user.go index d0ec4905..4a1b5d2b 100644 --- a/internal/view/web/auth/create_first_user.go +++ b/internal/view/web/auth/create_first_user.go @@ -116,6 +116,25 @@ func createFirstUserPage() nodx.Node { func (h *handlers) createFirstUserHandler(c echo.Context) error { ctx := c.Request().Context() + usersQty, err := h.servs.UsersService.GetUsersQty(ctx) + if err != nil { + logger.Error("failed to get users qty", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + "error": err, + }) + return c.String(http.StatusInternalServerError, "Internal server error") + } + if usersQty > 0 { + logger.Error("attempt to create a user when users already exist", logger.KV{ + "ip": c.RealIP(), + "ua": c.Request().UserAgent(), + }) + redirectPath := pathutil.BuildPath("/auth/login") + htmx.ServerSetRedirect(c.Response().Header(), redirectPath) + return c.Redirect(http.StatusFound, redirectPath) + } + var formData struct { Name string `form:"name" validate:"required"` Email string `form:"email" validate:"required,email"` @@ -129,7 +148,7 @@ func (h *handlers) createFirstUserHandler(c echo.Context) error { return respondhtmx.ToastError(c, err.Error()) } - _, err := h.servs.UsersService.CreateUser(ctx, dbgen.UsersServiceCreateUserParams{ + _, err = h.servs.UsersService.CreateUser(ctx, dbgen.UsersServiceCreateUserParams{ Name: formData.Name, Email: formData.Email, Password: formData.Password, diff --git a/internal/view/web/auth/router.go b/internal/view/web/auth/router.go index f04fb79e..1a0a823f 100644 --- a/internal/view/web/auth/router.go +++ b/internal/view/web/auth/router.go @@ -21,7 +21,10 @@ func MountRouter( requireNoAuth := parent.Group("", mids.RequireNoAuth) requireNoAuth.GET("/create-first-user", h.createFirstUserPageHandler) - requireNoAuth.POST("/create-first-user", h.createFirstUserHandler) + requireNoAuth.POST("/create-first-user", h.createFirstUserHandler, mids.RateLimit(middleware.RateLimitConfig{ + Limit: 5, + Period: 10 * time.Second, + })) requireNoAuth.GET("/login", h.loginPageHandler) requireNoAuth.POST("/login", h.loginHandler, mids.RateLimit(middleware.RateLimitConfig{ diff --git a/internal/view/web/layout/common.go b/internal/view/web/layout/common.go index 4cbb253f..fb679121 100644 --- a/internal/view/web/layout/common.go +++ b/internal/view/web/layout/common.go @@ -32,6 +32,7 @@ func commonHead() nodx.Node { return nodx.Group( nodx.Meta(nodx.Charset("utf-8")), nodx.Meta(nodx.Name("viewport"), nodx.Content("width=device-width, initial-scale=1")), + nodx.Meta(nodx.Name("robots"), nodx.Content("noindex, nofollow")), // Inject path prefix as global JavaScript variable nodx.Script(nodx.Rawf("window.PBW_PATH_PREFIX = '%s';", pathutil.GetPathPrefix())), From c045d19609d4d43da5ff10b68c6b7ff93a6a39d4 Mon Sep 17 00:00:00 2001 From: Muffin <74741218+AMufInABox@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:48:20 +0000 Subject: [PATCH 2/2] Update some files --- .gitignore | 3 +- compose.yml | 59 +++++++++++++++++++++++ internal/integration/postgres/postgres.go | 8 ++- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 compose.yml diff --git a/.gitignore b/.gitignore index ffdfe6fb..d61a0137 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ temp/ # Go workspace file go.work -go.work.sum \ No newline at end of file +go.work.sum +data* diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..b238c8fa --- /dev/null +++ b/compose.yml @@ -0,0 +1,59 @@ +services: + pgbackweb: + build: + context: . + dockerfile: Dockerfile + ports: + - "8085:8085" + env_file: + - .env + environment: + # Core app settings + PBW_ENCRYPTION_KEY: ${PBW_ENCRYPTION_KEY} + PBW_POSTGRES_CONN_STRING: ${PBW_POSTGRES_CONN_STRING} + TZ: ${TZ} + + # Enable OIDC + PBW_OIDC_ENABLED: ${PBW_OIDC_ENABLED} + PBW_OIDC_ISSUER_URL: ${PBW_OIDC_ISSUER_URL} + PBW_OIDC_CLIENT_ID: ${PBW_OIDC_CLIENT_ID} + PBW_OIDC_CLIENT_SECRET: ${PBW_OIDC_CLIENT_SECRET} + PBW_OIDC_REDIRECT_URL: ${PBW_OIDC_REDIRECT_URL} + + # Optional tuning + PBW_OIDC_SCOPES: ${PBW_OIDC_SCOPES} + PBW_OIDC_USERNAME_CLAIM: ${PBW_OIDC_USERNAME_CLAIM} + PBW_OIDC_EMAIL_CLAIM: ${PBW_OIDC_EMAIL_CLAIM} + PBW_OIDC_NAME_CLAIM: ${PBW_OIDC_NAME_CLAIM} + + extra_hosts: + - "host.docker.internal:host-gateway" + + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + networks: + - default + - authentik-network + + postgres: + image: postgres:18 + environment: + POSTGRES_USER: postgres + POSTGRES_DB: pgbackweb + POSTGRES_PASSWORD: password + ports: + - "5434:5432" + volumes: + - ./data:/var/lib/postgresql/18/docker + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 +networks: + default: + authentik-network: + external: true + name: authentik_authentik-network \ No newline at end of file diff --git a/internal/integration/postgres/postgres.go b/internal/integration/postgres/postgres.go index 97b2d64e..36462beb 100644 --- a/internal/integration/postgres/postgres.go +++ b/internal/integration/postgres/postgres.go @@ -92,16 +92,22 @@ func (Client) ParseVersion(version string) (PGVersion, error) { } // Test tests the connection to the PostgreSQL database -func (Client) Test(version PGVersion, connString string) error { +func (c Client) Test(version PGVersion, connString string) error { cmd := exec.Command(version.Value.PSQL, connString, "-c", "SELECT 1;") + fmt.Printf("Running command: %s %s -c 'SELECT 1;'\n", version.Value.PSQL, connString) + output, err := cmd.CombinedOutput() + fmt.Printf("Command output: %s\n", output) + if err != nil { + fmt.Printf("Command error: %v\n", err) return fmt.Errorf( "error running psql test v%s: %s", version.Value.Version, output, ) } + fmt.Println("PSQL test succeeded") return nil }