diff --git a/Makefile b/Makefile index e81a924c..b6443efc 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,9 @@ API_START_TIMEOUT=30s API_TIMEOUT=30s API_ROOT_PATH=/ API_TRACE_HEADER=Gotenberg-Trace +API_ENABLE_BASIC_AUTH=false +GOTENBERG_API_BASIC_AUTH_USERNAME= +GOTENBERG_API_BASIC_AUTH_PASSWORD= API_DISABLE_HEALTH_CHECK_LOGGING=false CHROMIUM_RESTART_AFTER=0 CHROMIUM_MAX_QUEUE_SIZE=0 @@ -79,6 +82,8 @@ WEBHOOK_DISABLE=false run: ## Start a Gotenberg container docker run --rm -it \ -p $(API_PORT):$(API_PORT) \ + -e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \ + -e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \ $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \ gotenberg \ --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ @@ -88,6 +93,7 @@ run: ## Start a Gotenberg container --api-timeout=$(API_TIMEOUT) \ --api-root-path=$(API_ROOT_PATH) \ --api-trace-header=$(API_TRACE_HEADER) \ + --api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \ --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ --chromium-auto-start=$(CHROMIUM_AUTO_START) \ diff --git a/go.mod b/go.mod index 234dfbcc..7357c19a 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.3.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hhrutter/lzw v1.0.0 // indirect github.com/hhrutter/tiff v1.0.1 // indirect @@ -63,6 +64,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 54ba587b..faa2773b 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= @@ -156,6 +158,8 @@ golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/pkg/gotenberg/env.go b/pkg/gotenberg/env.go new file mode 100644 index 00000000..72415f15 --- /dev/null +++ b/pkg/gotenberg/env.go @@ -0,0 +1,35 @@ +package gotenberg + +import ( + "fmt" + "os" + "strconv" +) + +// StringEnv retrieves the value of the environment variable named by the key. +// If the variable is present in the environment and not empty, the value is +// returned. +func StringEnv(key string) (string, error) { + val, ok := os.LookupEnv(key) + if !ok { + return "", fmt.Errorf("environment variable '%s' does not exist", key) + } + if val == "" { + return "", fmt.Errorf("environment variable '%s' is empty", key) + } + return val, nil +} + +// IntEnv relies on [StringEnv] and converts the values if it exists and is not +// empty. +func IntEnv(key string) (int, error) { + val, err := StringEnv(key) + if err != nil { + return 0, err + } + intVal, err := strconv.Atoi(val) + if err != nil { + return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err) + } + return intVal, nil +} diff --git a/pkg/gotenberg/env_test.go b/pkg/gotenberg/env_test.go new file mode 100644 index 00000000..4c14060e --- /dev/null +++ b/pkg/gotenberg/env_test.go @@ -0,0 +1,134 @@ +package gotenberg + +import ( + "os" + "testing" +) + +func TestStringEnv(t *testing.T) { + for _, tc := range []struct { + scenario string + key string + setEnv func() + expectVal string + expectError bool + }{ + { + scenario: "non-existing environment variable", + key: "NON_EXISTING", + expectVal: "", + expectError: true, + }, + { + scenario: "empty environment variable", + key: "EMPTY_STRING", + setEnv: func() { + err := os.Setenv("EMPTY_STRING", "") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }, + expectVal: "", + expectError: true, + }, + { + scenario: "success", + key: "EXISTING_STRING_VALUE", + setEnv: func() { + err := os.Setenv("EXISTING_STRING_VALUE", "foo") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }, + expectVal: "foo", + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + if tc.setEnv != nil { + tc.setEnv() + } + + val, err := StringEnv(tc.key) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + + if tc.expectVal != val { + t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val) + } + }) + } +} + +func TestIntEnv(t *testing.T) { + for _, tc := range []struct { + scenario string + key string + setEnv func() + expectVal int + expectError bool + }{ + { + scenario: "empty environment variable", + key: "EMPTY_INT", + setEnv: func() { + err := os.Setenv("EMPTY_INT", "") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }, + expectVal: 0, + expectError: true, + }, + { + scenario: "non-integer value", + key: "NON_INTEGER", + setEnv: func() { + err := os.Setenv("NON_INTEGER", "foo") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }, + expectVal: 0, + expectError: true, + }, + { + scenario: "success", + key: "EXISTING_INT_VALUE", + setEnv: func() { + err := os.Setenv("EXISTING_INT_VALUE", "123") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + }, + expectVal: 123, + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + if tc.setEnv != nil { + tc.setEnv() + } + + val, err := IntEnv(tc.key) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + + if tc.expectVal != val { + t.Errorf("expected value %d but got %d", tc.expectVal, val) + } + }) + } +} diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index 8c4f77e7..e7d7c979 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "net/http" - "os" "sort" - "strconv" "strings" "time" @@ -34,6 +32,8 @@ type Api struct { timeout time.Duration rootPath string traceHeader string + basicAuthUsername string + basicAuthPassword string disableHealthCheckLogging bool routes []Route @@ -163,8 +163,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests") fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") + fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") - return fs }(), New: func() gotenberg.Module { return new(Api) }, @@ -184,24 +184,28 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { // Port from env? portEnvVar := flags.MustString("api-port-from-env") if portEnvVar != "" { - val, ok := os.LookupEnv(portEnvVar) - - if !ok { - return fmt.Errorf("environment variable '%s' does not exist", portEnvVar) - } - - if val == "" { - return fmt.Errorf("environment variable '%s' is empty", portEnvVar) - } - - port, err := strconv.Atoi(val) + port, err := gotenberg.IntEnv(portEnvVar) if err != nil { - return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err) + return fmt.Errorf("get API port from env: %w", err) } - a.port = port } + // Enable basic auth? + enableBasicAuth := flags.MustBool("api-enable-basic-auth") + if enableBasicAuth { + basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME") + if err != nil { + return fmt.Errorf("get basic auth username from env: %w", err) + } + basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD") + if err != nil { + return fmt.Errorf("get basic auth password from env: %w", err) + } + a.basicAuthUsername = basicAuthUsername + a.basicAuthPassword = basicAuthPassword + } + // Get routes from modules. mods, err := ctx.Modules(new(Router)) if err != nil { @@ -394,6 +398,13 @@ func (a *Api) Start() error { loggerMiddleware(a.logger, disableLoggingForPaths), ) + // Basic auth? + if a.basicAuthUsername != "" { + a.srv.Pre( + basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword), + ) + } + // Add the modules' middlewares in their respective stacks. var externalMultipartMiddlewares []Middleware for _, externalMiddleware := range a.externalMiddlewares { diff --git a/pkg/modules/api/api_test.go b/pkg/modules/api/api_test.go index 20057649..a5e5b0fb 100644 --- a/pkg/modules/api/api_test.go +++ b/pkg/modules/api/api_test.go @@ -58,10 +58,28 @@ func TestApi_Provision(t *testing.T) { expectError: true, }, { - scenario: "port from env: empty environment variable", + scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable", ctx: func() *gotenberg.Context { fs := new(Api).Descriptor().FlagSet - err := fs.Parse([]string{"--api-port-from-env=PORT"}) + err := fs.Parse([]string{"--api-enable-basic-auth=true"}) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: fs, + }, + nil, + ) + }(), + expectError: true, + }, + { + scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_PASSWORD environment variable", + ctx: func() *gotenberg.Context { + fs := new(Api).Descriptor().FlagSet + err := fs.Parse([]string{"--api-enable-basic-auth=true"}) if err != nil { t.Fatalf("expected no error but got: %v", err) } @@ -74,7 +92,7 @@ func TestApi_Provision(t *testing.T) { ) }(), setEnv: func() { - err := os.Setenv("PORT", "") + err := os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo") if err != nil { t.Fatalf("expected no error but got: %v", err) } @@ -361,7 +379,7 @@ func TestApi_Provision(t *testing.T) { } fs := new(Api).Descriptor().FlagSet - err := fs.Parse([]string{"--api-port-from-env=PORT"}) + err := fs.Parse([]string{"--api-port-from-env=PORT", "--api-enable-basic-auth=true"}) if err != nil { t.Fatalf("expected no error but got: %v", err) } @@ -383,6 +401,14 @@ func TestApi_Provision(t *testing.T) { if err != nil { t.Fatalf("expected no error but got: %v", err) } + err = os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + err = os.Setenv("GOTENBERG_API_BASIC_AUTH_PASSWORD", "bar") + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } }, expectPort: 1337, expectMiddlewares: []Middleware{ @@ -671,6 +697,8 @@ func TestApi_Start(t *testing.T) { mod.port = 3000 mod.startTimeout = time.Duration(30) * time.Second mod.rootPath = "/" + mod.basicAuthUsername = "foo" + mod.basicAuthPassword = "bar" mod.disableHealthCheckLogging = true mod.routes = []Route{ { @@ -755,6 +783,7 @@ func TestApi_Start(t *testing.T) { // health request. recorder := httptest.NewRecorder() healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil) + healthRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword) mod.srv.ServeHTTP(recorder, healthRequest) if recorder.Code != http.StatusOK { @@ -791,6 +820,7 @@ func TestApi_Start(t *testing.T) { req := httptest.NewRequest(http.MethodPost, url, body) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + req.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword) return req } diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index baa7df97..9a1f4183 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -2,6 +2,7 @@ package api import ( "context" + "crypto/subtle" "errors" "fmt" "net/http" @@ -10,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" @@ -112,7 +114,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { c.Set("rootPath", rootPath) - // Call the next middleware in the chain. return next(c) } @@ -217,6 +218,17 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo. } } +// basicAuthMiddleware manages basic authentication. +func basicAuthMiddleware(username, password string) echo.MiddlewareFunc { + return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) { + if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 && + subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 { + return true, nil + } + return false, nil + }) +} + // contextMiddleware, a middleware for "multipart/form-data" requests, sets the // [Context] and related context.CancelFunc in the [echo.Context] under // "context" and "cancel". If the process is synchronous, it also handles the diff --git a/pkg/modules/api/middlewares_test.go b/pkg/modules/api/middlewares_test.go index 4490abc0..b25baf71 100644 --- a/pkg/modules/api/middlewares_test.go +++ b/pkg/modules/api/middlewares_test.go @@ -236,6 +236,56 @@ func TestTraceMiddleware(t *testing.T) { } } +func TestBasicAuthMiddleware(t *testing.T) { + for _, tc := range []struct { + scenario string + request *http.Request + username string + password string + expectError bool + }{ + { + scenario: "invalid basic auth", + request: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("invalid", "invalid") + return req + }(), + username: "foo", + password: "bar", + expectError: true, + }, + { + scenario: "valid basic auth", + request: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("foo", "bar") + return req + }(), + username: "foo", + password: "bar", + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + recorder := httptest.NewRecorder() + srv := echo.New() + srv.HideBanner = true + srv.HidePort = true + c := srv.NewContext(tc.request, recorder) + err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error { + return nil + })(c) + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + func TestLoggerMiddleware(t *testing.T) { for i, tc := range []struct { request *http.Request