Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Commit

Permalink
Add application-level per-realm firewall configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
sethvargo committed Sep 23, 2020
1 parent dce306c commit 5341046
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 8 deletions.
51 changes: 51 additions & 0 deletions cmd/server/assets/realmadmin/_form_security.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<option value="0" {{if eq $realm.EmailVerifiedMode.String "prompt"}}selected{{end}}>Prompt after login</option>
<option value="2" {{if eq $realm.EmailVerifiedMode.String "optional"}}selected{{end}}>Optional</option>
</select>
{{template "errorable" $realm.ErrorsFor "emailVerifiedMode"}}
<small class="form-text text-muted">
Email verification requires users to verify their email address before
using the system.
Expand All @@ -30,6 +31,7 @@
<option value="0" {{if eq $realm.MFAMode.String "prompt"}}selected{{end}}>Prompt after login</option>
<option value="2" {{if eq $realm.MFAMode.String "optional"}}selected{{end}}>Optional</option>
</select>
{{template "errorable" $realm.ErrorsFor "mfaMode"}}
<small class="form-text text-muted">
Multi-factor authentication requires users to supply a second factor (e.g.
a code via an SMS text message) when authenticating to the system.
Expand All @@ -44,6 +46,7 @@
<option value="{{$prd}}" {{if (eq $prd $current)}}selected{{end}}>{{if (eq $prd 0)}}Off{{else}}Every {{$prd}} days{{end}}</option>
{{end}}
</select>
{{template "errorable" $realm.ErrorsFor "passwordRotationPeriodDays"}}
<small class="form-text text-muted">
If enabled, users will be required to change their password after this
number of days elapse since their last password change.
Expand All @@ -58,12 +61,60 @@
<option value="{{$pwd }}" {{if (eq $pwd $current)}}selected{{end}}>{{if (eq $pwd 0)}}Off{{else}}{{$pwd}} days before{{end}}</option>
{{end}}
</select>
{{template "errorable" $realm.ErrorsFor "passwordRotationWarningDays"}}
<small class="form-text text-muted">
If enabled, users will be warned to change their password within this
number of days before expiration.
</small>
</div>

<div class="form-label-group">
<textarea name="allowed_cidrs_adminapi" id="allowed-cidrs-adminapi" class="form-control text-monospace{{if $realm.ErrorsFor "allowedCIDRsAdminAPI"}} is-invalid{{end}}"
rows="5" placeholder="Allowed CIDRs (Admin API)">{{joinStrings $realm.AllowedCIDRsAdminAPI "\n"}}</textarea>
<label for="allowed-cidrs-adminapi">Allowed CIDRs (Admin API)</label>
{{template "errorable" $realm.ErrorsFor "allowedCIDRsAdminAPI"}}
<small class="form-text text-muted">
An optional list of CIDR blocks from which to allow traffic to the
<strong>Admin API</strong> which can be used to generate codes. If blank,
all traffic is allowed from all IPs. These should be of <a
href="https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing"
target="_BLANK">CIDR notation</a>
of the format (e.g. <code>192.1.2.0/24</code>).
</small>
</div>

<div class="form-label-group">
<textarea name="allowed_cidrs_apiserver" id="allowed-cidrs-apiserver" class="form-control text-monospace{{if $realm.ErrorsFor "allowedCIDRsAPIServer"}} is-invalid{{end}}"
rows="5" placeholder="Allowed CIDRs (Device API)">{{joinStrings $realm.AllowedCIDRsAPIServer "\n"}}</textarea>
<label for="allowed-cidrs-apiserver">Allowed CIDRs (Device API)</label>
{{template "errorable" $realm.ErrorsFor "allowedCIDRsAPIServer"}}
<small class="form-text text-muted">
An optional list of CIDR blocks from which to allow traffic to the
<strong>Device API</strong> which is where devices exchange their code for
a certificate. It is highly recommended that you <strong>leave this
service publicly accessible</strong>. If blank, all traffic is allowed
from all IPs. These should be of <a
href="https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing"
target="_BLANK">CIDR notation</a>
of the format (e.g. <code>192.1.2.0/24</code>).
</small>
</div>

<div class="form-label-group">
<textarea name="allowed_cidrs_server" id="allowed-cidrs-server" class="form-control text-monospace{{if $realm.ErrorsFor "allowedCIDRsServer"}} is-invalid{{end}}"
rows="5" placeholder="Allowed CIDRs (UI server)">{{joinStrings $realm.AllowedCIDRsServer "\n"}}</textarea>
<label for="allowed-cidrs-server">Allowed CIDRs (UI server)</label>
{{template "errorable" $realm.ErrorsFor "allowedCIDRsServer"}}
<small class="form-text text-muted">
An optional list of CIDR blocks from which to allow traffic to the
<strong>UI server</strong> (this server). If blank, all traffic is allowed
from all IPs. These should be of <a
href="https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing"
target="_BLANK">CIDR notation</a>
of the format (e.g. <code>192.1.2.0/24</code>).
</small>
</div>

<div class="mt-4">
<input type="submit" class="btn btn-primary btn-block" value="Update security settings" />
</div>
Expand Down
11 changes: 11 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

{
Expand Down Expand Up @@ -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.
Expand All @@ -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")
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions pkg/controller/middleware/firewall.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
17 changes: 12 additions & 5 deletions pkg/controller/realmadmin/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions pkg/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},
Expand Down
38 changes: 38 additions & 0 deletions pkg/database/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import (
"errors"
"fmt"
"math"
"sort"
"strings"
"time"

"github.com/google/exposure-notifications-verification-server/pkg/sms"
"github.com/microcosm-cc/bluemonday"

"github.com/jinzhu/gorm"
"github.com/lib/pq"
"github.com/russross/blackfriday/v2"
)

Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions pkg/ratelimit/limitware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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
Expand Down

0 comments on commit 5341046

Please sign in to comment.