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/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/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 } 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 6156b575..a7b2947b 100644 --- a/internal/view/web/auth/create_first_user.go +++ b/internal/view/web/auth/create_first_user.go @@ -117,6 +117,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"` @@ -130,7 +149,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: sql.NullString{String: formData.Password, Valid: true}, diff --git a/internal/view/web/auth/router.go b/internal/view/web/auth/router.go index 8cbeb6eb..d3ceee74 100644 --- a/internal/view/web/auth/router.go +++ b/internal/view/web/auth/router.go @@ -22,7 +22,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())),