From 53410468b4baf48efcdd3c69fcbda5b0938dba8a Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Tue, 22 Sep 2020 22:33:01 -0400 Subject: [PATCH] Add application-level per-realm firewall configuration --- .../assets/realmadmin/_form_security.html | 51 ++++++++++ cmd/server/main.go | 11 +++ pkg/controller/middleware/firewall.go | 94 +++++++++++++++++++ pkg/controller/realmadmin/settings.go | 17 +++- pkg/database/migrations.go | 21 +++++ pkg/database/realm.go | 38 ++++++++ pkg/ratelimit/limitware/middleware.go | 6 +- 7 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 pkg/controller/middleware/firewall.go diff --git a/cmd/server/assets/realmadmin/_form_security.html b/cmd/server/assets/realmadmin/_form_security.html index 9ce885ebd..8b09f150d 100644 --- a/cmd/server/assets/realmadmin/_form_security.html +++ b/cmd/server/assets/realmadmin/_form_security.html @@ -17,6 +17,7 @@ + {{template "errorable" $realm.ErrorsFor "emailVerifiedMode"}} Email verification requires users to verify their email address before using the system. @@ -30,6 +31,7 @@ + {{template "errorable" $realm.ErrorsFor "mfaMode"}} Multi-factor authentication requires users to supply a second factor (e.g. a code via an SMS text message) when authenticating to the system. @@ -44,6 +46,7 @@ {{end}} + {{template "errorable" $realm.ErrorsFor "passwordRotationPeriodDays"}} If enabled, users will be required to change their password after this number of days elapse since their last password change. @@ -58,12 +61,60 @@ {{end}} + {{template "errorable" $realm.ErrorsFor "passwordRotationWarningDays"}} If enabled, users will be warned to change their password within this number of days before expiration. +
+ + + {{template "errorable" $realm.ErrorsFor "allowedCIDRsAdminAPI"}} + + An optional list of CIDR blocks from which to allow traffic to the + Admin API which can be used to generate codes. If blank, + all traffic is allowed from all IPs. These should be of CIDR notation + of the format (e.g. 192.1.2.0/24). + +
+ +
+ + + {{template "errorable" $realm.ErrorsFor "allowedCIDRsAPIServer"}} + + An optional list of CIDR blocks from which to allow traffic to the + Device API which is where devices exchange their code for + a certificate. It is highly recommended that you leave this + service publicly accessible. If blank, all traffic is allowed + from all IPs. These should be of CIDR notation + of the format (e.g. 192.1.2.0/24). + +
+ +
+ + + {{template "errorable" $realm.ErrorsFor "allowedCIDRsServer"}} + + An optional list of CIDR blocks from which to allow traffic to the + UI server (this server). If blank, all traffic is allowed + from all IPs. These should be of CIDR notation + of the format (e.g. 192.1.2.0/24). + +
+
diff --git a/cmd/server/main.go b/cmd/server/main.go index c90255b20..81a5a1cf4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -193,6 +193,7 @@ func realMain(ctx context.Context) error { requireRealm := middleware.RequireRealm(ctx, h) requireSystemAdmin := middleware.RequireAdmin(ctx, h) requireMFA := middleware.RequireMFA(ctx, h) + processFirewall := middleware.ProcessFirewall(ctx, h, "server") rateLimit := httplimiter.Handle { @@ -241,6 +242,7 @@ func realMain(ctx context.Context) error { sub.Use(rateLimit) sub.Use(loadCurrentRealm) sub.Use(requireRealm) + sub.Use(processFirewall) sub.Handle("/login/verify-email", loginController.HandleVerifyEmail()).Methods("GET") // SMS auth registration is realm-specific, so it needs to load the current realm. @@ -249,6 +251,7 @@ func realMain(ctx context.Context) error { sub.Use(rateLimit) sub.Use(loadCurrentRealm) sub.Use(requireRealm) + sub.Use(processFirewall) sub.Use(requireVerified) sub.Handle("/login/register-phone", loginController.HandleRegisterPhone()).Methods("GET") } @@ -259,6 +262,7 @@ func realMain(ctx context.Context) error { sub.Use(requireAuth) sub.Use(loadCurrentRealm) sub.Use(requireRealm) + sub.Use(processFirewall) sub.Use(requireVerified) sub.Use(requireMFA) sub.Use(rateLimit) @@ -279,6 +283,7 @@ func realMain(ctx context.Context) error { sub.Use(requireAuth) sub.Use(loadCurrentRealm) sub.Use(requireRealm) + sub.Use(processFirewall) sub.Use(requireVerified) sub.Use(requireMFA) sub.Use(rateLimit) @@ -294,6 +299,8 @@ func realMain(ctx context.Context) error { sub := r.PathPrefix("/apikeys").Subrouter() sub.Use(requireAuth) sub.Use(loadCurrentRealm) + sub.Use(requireRealm) + sub.Use(processFirewall) sub.Use(requireAdmin) sub.Use(requireVerified) sub.Use(requireMFA) @@ -315,6 +322,8 @@ func realMain(ctx context.Context) error { userSub := r.PathPrefix("/users").Subrouter() userSub.Use(requireAuth) userSub.Use(loadCurrentRealm) + userSub.Use(requireRealm) + userSub.Use(processFirewall) userSub.Use(requireAdmin) userSub.Use(requireVerified) userSub.Use(requireMFA) @@ -338,6 +347,8 @@ func realMain(ctx context.Context) error { realmSub := r.PathPrefix("/realm").Subrouter() realmSub.Use(requireAuth) realmSub.Use(loadCurrentRealm) + realmSub.Use(requireRealm) + realmSub.Use(processFirewall) realmSub.Use(requireAdmin) realmSub.Use(requireVerified) realmSub.Use(requireMFA) diff --git a/pkg/controller/middleware/firewall.go b/pkg/controller/middleware/firewall.go new file mode 100644 index 000000000..1b499164e --- /dev/null +++ b/pkg/controller/middleware/firewall.go @@ -0,0 +1,94 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package middleware + +import ( + "context" + "net" + "net/http" + "strings" + + "github.com/google/exposure-notifications-verification-server/pkg/controller" + "github.com/google/exposure-notifications-verification-server/pkg/render" + + "github.com/google/exposure-notifications-server/pkg/logging" + + "github.com/gorilla/mux" +) + +// ProcessFirewall verifies the application-level firewall configuration. +// +// This must come after the realm has been loaded in the context, probably via a +// different middleware. +func ProcessFirewall(ctx context.Context, h *render.Renderer, typ string) mux.MiddlewareFunc { + logger := logging.FromContext(ctx).Named("middleware.ProcessFirewall") + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + realm := controller.RealmFromContext(ctx) + if realm == nil { + controller.MissingRealm(w, r, h) + return + } + + var allowedCIDRs []string + switch typ { + case "adminapi": + allowedCIDRs = realm.AllowedCIDRsAdminAPI + case "apiserver": + allowedCIDRs = realm.AllowedCIDRsAPIServer + case "server": + allowedCIDRs = realm.AllowedCIDRsServer + } + + // If there's no CIDRs, all traffic is allowed. + if len(allowedCIDRs) == 0 { + next.ServeHTTP(w, r) + return + } + + logger.Debugw("validating ip in cidr block", "type", typ) + + // Get the remote address. + ipStr := r.RemoteAddr + + // Check if x-forwarded-for exists, the load balancer sets this, and the + // first entry is the real client IP. + xff := r.Header.Get("x-forwarded-for") + if xff != "" { + ipStr = strings.Split(xff, ",")[0] + } + + ip := net.ParseIP(ipStr) + for _, c := range allowedCIDRs { + _, cidr, err := net.ParseCIDR(c) + if err != nil { + logger.Warnw("failed to parse cidr", "cidr", c, "error", err) + continue + } + + if cidr.Contains(ip) { + next.ServeHTTP(w, r) + return + } + } + + logger.Errorw("ip is not in an allowed cidr block") + controller.Unauthorized(w, r, h) + }) + } +} diff --git a/pkg/controller/realmadmin/settings.go b/pkg/controller/realmadmin/settings.go index 03c2de66b..af11b6649 100644 --- a/pkg/controller/realmadmin/settings.go +++ b/pkg/controller/realmadmin/settings.go @@ -66,11 +66,14 @@ func (c *Controller) HandleSettings() http.Handler { TwilioAuthToken string `form:"twilio_auth_token"` TwilioFromNumber string `form:"twilio_from_number"` - Security bool `form:"security"` - MFAMode int16 `form:"mfa_mode"` - EmailVerifiedMode int16 `form:"email_verified_mode"` - PasswordRotationPeriodDays uint `form:"password_rotation_period_days"` - PasswordRotationWarningDays uint `form:"password_rotation_warning_days"` + Security bool `form:"security"` + MFAMode int16 `form:"mfa_mode"` + EmailVerifiedMode int16 `form:"email_verified_mode"` + PasswordRotationPeriodDays uint `form:"password_rotation_period_days"` + PasswordRotationWarningDays uint `form:"password_rotation_warning_days"` + AllowedCIDRsAdminAPI string `form:"allowed_cidrs_adminapi"` + AllowedCIDRsAPIServer string `form:"allowed_cidrs_apiserver"` + AllowedCIDRsServer string `form:"allowed_cidrs_server"` AbusePrevention bool `form:"abuse_prevention"` AbusePreventionEnabled bool `form:"abuse_prevention_enabled"` @@ -135,6 +138,10 @@ func (c *Controller) HandleSettings() http.Handler { realm.MFAMode = database.AuthRequirement(form.MFAMode) realm.PasswordRotationPeriodDays = form.PasswordRotationPeriodDays realm.PasswordRotationWarningDays = form.PasswordRotationWarningDays + + realm.AllowedCIDRsAdminAPI = database.ToCIDRList(form.AllowedCIDRsAdminAPI) + realm.AllowedCIDRsAPIServer = database.ToCIDRList(form.AllowedCIDRsAPIServer) + realm.AllowedCIDRsServer = database.ToCIDRList(form.AllowedCIDRsServer) } // Abuse prevention diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index ee771045b..2db10dd2f 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -1275,6 +1275,27 @@ func (db *Database) getMigrations(ctx context.Context) *gormigrate.Gormigrate { } } + return nil + }, + }, + { + ID: "00052-CreateRealmAllowedCIDRs", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate(&Realm{}).Error + }, + Rollback: func(tx *gorm.DB) error { + sqls := []string{ + `ALTER TABLE realms DROP COLUMN IF EXISTS allowed_cidrs_adminapi`, + `ALTER TABLE realms DROP COLUMN IF EXISTS allowed_cidrs_apiserver`, + `ALTER TABLE realms DROP COLUMN IF EXISTS allowed_cidrs_server`, + } + + for _, sql := range sqls { + if err := tx.Exec(sql).Error; err != nil { + return err + } + } + return nil }, }, diff --git a/pkg/database/realm.go b/pkg/database/realm.go index de61a9c97..44febf31b 100644 --- a/pkg/database/realm.go +++ b/pkg/database/realm.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" "time" @@ -26,6 +27,7 @@ import ( "github.com/microcosm-cc/bluemonday" "github.com/jinzhu/gorm" + "github.com/lib/pq" "github.com/russross/blackfriday/v2" ) @@ -126,6 +128,11 @@ type Realm struct { // that the user should receive a warning. PasswordRotationWarningDays uint `gorm:"type:smallint; not null; default: 0"` + // AllowedCIDRs is the list of allowed IPs to the various services. + AllowedCIDRsAdminAPI pq.StringArray `gorm:"column:allowed_cidrs_adminapi; type:varchar(50)[];"` + AllowedCIDRsAPIServer pq.StringArray `gorm:"column:allowed_cidrs_apiserver; type:varchar(50)[];"` + AllowedCIDRsServer pq.StringArray `gorm:"column:allowed_cidrs_server; type:varchar(50)[];"` + // AllowedTestTypes is the type of tests that this realm permits. The default // value is to allow all test types. AllowedTestTypes TestType `gorm:"type:smallint; not null; default: 14"` @@ -816,3 +823,34 @@ func (r *Realm) RenderWelcomeMessage() string { raw := blackfriday.Run([]byte(msg)) return string(bluemonday.UGCPolicy().SanitizeBytes(raw)) } + +// ToCIDRList converts the newline-separated and/or comma-separated CIDR list +// into an array of strings. +func ToCIDRList(s string) []string { + var cidrs []string + for _, line := range strings.Split(s, "\n") { + for _, v := range strings.Split(line, ",") { + v = strings.TrimSpace(v) + + // Ignore blanks + if v == "" { + continue + } + + // If there's no /, assume the most specific. This is intentionally + // rudimentary. + if !strings.Contains(v, "/") { + if strings.Contains(v, ":") { + v = fmt.Sprintf("%s/128", v) + } else { + v = fmt.Sprintf("%s/32", v) + } + } + + cidrs = append(cidrs, v) + } + } + + sort.Strings(cidrs) + return cidrs +} diff --git a/pkg/ratelimit/limitware/middleware.go b/pkg/ratelimit/limitware/middleware.go index 98f3f0177..2c97617da 100644 --- a/pkg/ratelimit/limitware/middleware.go +++ b/pkg/ratelimit/limitware/middleware.go @@ -199,7 +199,7 @@ func (m *Middleware) Handle(next http.Handler) http.Handler { // header. Since APIKeys are assumed to be "public" at some point, they are rate // limited by [realm,ip], and API keys have a 1-1 mapping to a realm. func APIKeyFunc(ctx context.Context, db *database.Database, scope string, hmacKey []byte) httplimit.KeyFunc { - logger := logging.FromContext(ctx).Named(scope + ".ratelimit") + logger := logging.FromContext(ctx).Named("ratelimit.APIKeyFunc") ipAddrLimit := IPAddressKeyFunc(ctx, scope, hmacKey) return func(r *http.Request) (string, error) { @@ -224,7 +224,7 @@ func APIKeyFunc(ctx context.Context, db *database.Database, scope string, hmacKe // UserIDKeyFunc pulls the user out of the request context and uses that to // ratelimit. It falls back to rate limiting by the client ip. func UserIDKeyFunc(ctx context.Context, scope string, hmacKey []byte) httplimit.KeyFunc { - logger := logging.FromContext(ctx).Named(scope + ".ratelimit") + logger := logging.FromContext(ctx).Named("ratelimit.UserIDKeyFunc") ipAddrLimit := IPAddressKeyFunc(ctx, scope, hmacKey) return func(r *http.Request) (string, error) { @@ -247,7 +247,7 @@ func UserIDKeyFunc(ctx context.Context, scope string, hmacKey []byte) httplimit. // IPAddressKeyFunc uses the client IP to rate limit. func IPAddressKeyFunc(ctx context.Context, scope string, hmacKey []byte) httplimit.KeyFunc { - logger := logging.FromContext(ctx).Named(scope + ".ratelimit") + logger := logging.FromContext(ctx).Named("ratelimit.IPAddressKeyFunc") return func(r *http.Request) (string, error) { // Get the remote addr