Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ temp/

# Go workspace file
go.work
go.work.sum
go.work.sum
data*
59 changes: 59 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion internal/integration/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
14 changes: 14 additions & 0 deletions internal/view/middleware/no_index.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions internal/view/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
21 changes: 20 additions & 1 deletion internal/view/web/auth/create_first_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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},
Expand Down
5 changes: 4 additions & 1 deletion internal/view/web/auth/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions internal/view/web/layout/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
Loading