Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add --metadata-protection #234

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ Usage of kube2iam:
--iam-external-id string Pod annotation key used to retrieve the IAM ExternalId (default "iam.amazonaws.com/external-id")
--insecure Kubernetes server should be accessed without verifying the TLS. Testing only
--iptables Add iptables rule (also requires --host-ip)
--metadata-protection Block metadata requests that don't have a correct AWS User Agent
--log-format string Log format (text/json) (default "text")
--log-level string Log level (default "info")
--metadata-addr string Address for the ec2 metadata (default "169.254.169.254")
Expand Down
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func addFlags(s *server.Server, fs *pflag.FlagSet) {
fs.BoolVar(&s.Insecure, "insecure", false, "Kubernetes server should be accessed without verifying the TLS. Testing only")
fs.StringVar(&s.MetadataAddress, "metadata-addr", s.MetadataAddress, "Address for the ec2 metadata")
fs.BoolVar(&s.AddIPTablesRule, "iptables", false, "Add iptables rule (also requires --host-ip)")
fs.BoolVar(&s.MetadataProtection, "metadata-protection", false, "Block metadata requests that don't have a correct AWS User Agent")
fs.BoolVar(&s.AutoDiscoverBaseArn, "auto-discover-base-arn", false, "Queries EC2 Metadata to determine the base ARN")
fs.BoolVar(&s.AutoDiscoverDefaultRole, "auto-discover-default-role", false, "Queries EC2 Metadata to determine the default Iam Role and base ARN, cannot be used with --default-role, overwrites any previous setting for --base-role-arn")
fs.StringVar(&s.HostInterface, "host-interface", "docker0", "Host interface for proxying AWS metadata")
Expand Down
2 changes: 1 addition & 1 deletion glide.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 9 additions & 9 deletions mappings/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (

glob "github.com/ryanuber/go-glob"
log "github.com/sirupsen/logrus"
"k8s.io/client-go/pkg/api/v1"
v1 "k8s.io/client-go/pkg/api/v1"

"github.com/jtblin/kube2iam"
"github.com/jtblin/kube2iam/iam"
Expand Down Expand Up @@ -100,7 +100,7 @@ func (r *RoleMapper) checkRoleForNamespace(roleArn string, namespace string) boo

ns, err := r.store.NamespaceByName(namespace)
if err != nil {
log.Debug("Unable to find an indexed namespace of %s", namespace)
log.Debugf("Unable to find an indexed namespace of %s", namespace)
return false
}

Expand Down Expand Up @@ -163,13 +163,13 @@ func (r *RoleMapper) DumpDebugInfo() map[string]interface{} {
// NewRoleMapper returns a new RoleMapper for use.
func NewRoleMapper(roleKey string, externalIDKey string, defaultRole string, namespaceRestriction bool, namespaceKey string, iamInstance *iam.Client, kubeStore store, namespaceRestrictionFormat string) *RoleMapper {
return &RoleMapper{
defaultRoleARN: iamInstance.RoleARN(defaultRole),
iamRoleKey: roleKey,
iamExternalIDKey: externalIDKey,
namespaceKey: namespaceKey,
namespaceRestriction: namespaceRestriction,
iam: iamInstance,
store: kubeStore,
defaultRoleARN: iamInstance.RoleARN(defaultRole),
iamRoleKey: roleKey,
iamExternalIDKey: externalIDKey,
namespaceKey: namespaceKey,
namespaceRestriction: namespaceRestriction,
iam: iamInstance,
store: kubeStore,
namespaceRestrictionFormat: namespaceRestrictionFormat,
}
}
76 changes: 61 additions & 15 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const (
// Keeps track of the names of registered handlers for metric value/label initialization
var registeredHandlerNames []string

// Allowed user-agent prefixes when --metadata-protection is enabled
var userAgentPrefixAllowlist []string

// Server encapsulates all of the parameters necessary for starting up
// the server. These can either be set via command line or directly.
type Server struct {
Expand All @@ -65,6 +68,7 @@ type Server struct {
NamespaceRestrictionFormat string
UseRegionalStsEndpoint bool
AddIPTablesRule bool
MetadataProtection bool
AutoDiscoverBaseArn bool
AutoDiscoverDefaultRole bool
Debug bool
Expand All @@ -80,6 +84,7 @@ type Server struct {
InstanceID string
HealthcheckFailReason string
healthcheckTicker *time.Ticker
server *http.Server
}

type appHandlerFunc func(*log.Entry, http.ResponseWriter, *http.Request)
Expand All @@ -94,6 +99,10 @@ type responseWriter struct {
statusCode int
}

func init() {
userAgentPrefixAllowlist = []string{"aws-sdk-", "Botocore/", "Boto3/", "aws-cli/", "aws-chalice/"}
}

func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
Expand Down Expand Up @@ -365,8 +374,20 @@ func write(logger *log.Entry, w http.ResponseWriter, s string) {
}
}

// Run runs the specified Server.
func (s *Server) Run(host, token, nodeName string, insecure bool) error {
func (s *Server) checkUserAgent(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userAgent := r.UserAgent()
for _, prefix := range userAgentPrefixAllowlist {
if strings.HasPrefix(userAgent, prefix) {
next.ServeHTTP(w, r)
return
}
}
http.Error(w, fmt.Sprintf("User-agent '%s' is not allowed", userAgent), http.StatusForbidden)
})
}

func (s *Server) setup(host, token, nodeName string, insecure bool) error {
k, err := k8s.NewClient(host, token, nodeName, insecure)
if err != nil {
return err
Expand All @@ -393,17 +414,7 @@ func (s *Server) Run(host, token, nodeName string, insecure bool) error {
s.beginPollHealthcheck(healthcheckInterval)

r := mux.NewRouter()
securityHandler := newAppHandler("securityCredentialsHandler", s.securityCredentialsHandler)

if s.Debug {
// This is a potential security risk if enabled in some clusters, hence the flag
r.Handle("/debug/store", newAppHandler("debugStoreHandler", s.debugStoreHandler))
}
r.Handle("/{version}/meta-data/iam/security-credentials", securityHandler)
r.Handle("/{version}/meta-data/iam/security-credentials/", securityHandler)
r.Handle(
"/{version}/meta-data/iam/security-credentials/{role:.*}",
newAppHandler("roleHandler", s.roleHandler))
r.Handle("/healthz", newAppHandler("healthHandler", s.healthHandler))

if s.MetricsPort == s.AppPort {
Expand All @@ -412,13 +423,48 @@ func (s *Server) Run(host, token, nodeName string, insecure bool) error {
metrics.StartMetricsServer(s.MetricsPort)
}

if s.Debug {
// This is a potential security risk if enabled in some clusters, hence the flag
r.Handle("/debug/store", newAppHandler("debugStoreHandler", s.debugStoreHandler))
}

sr := r.NewRoute().Subrouter()
if s.MetadataProtection {
// All routes added to this subrouter will have user-agent validation
sr.Use(s.checkUserAgent)
}

securityHandler := newAppHandler("securityCredentialsHandler", s.securityCredentialsHandler)

sr.Handle("/{version}/meta-data/iam/security-credentials", securityHandler)
sr.Handle("/{version}/meta-data/iam/security-credentials/", securityHandler)
sr.Handle(
"/{version}/meta-data/iam/security-credentials/{role:.*}",
newAppHandler("roleHandler", s.roleHandler))

// This has to be registered last so that it catches fall-throughs
r.Handle("/{path:.*}", newAppHandler("reverseProxyHandler", s.reverseProxyHandler))
sr.Handle("/{path:.*}", newAppHandler("reverseProxyHandler", s.reverseProxyHandler))

log.Infof("Listening on port %s", s.AppPort)
if err := http.ListenAndServe(":"+s.AppPort, r); err != nil {
srv := http.Server{
Addr: ":" + s.AppPort,
Handler: r,
}
s.server = &srv

return nil
}

// Run runs the specified Server.
func (s *Server) Run(host, token, nodeName string, insecure bool) error {
err := s.setup(host, token, nodeName, insecure)
if err != nil {
log.Fatalf("Error creating kube2iam http server: %+v", err)
}
log.Infof("Listening on port %s", s.AppPort)
if err = s.server.ListenAndServe(); err != nil {
log.Fatalf("Error starting http server: %+v", err)
}

return nil
}

Expand Down
167 changes: 167 additions & 0 deletions server/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package server

import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"k8s.io/client-go/pkg/api/unversioned"

v1 "k8s.io/client-go/pkg/api/v1"
)

func withServer(t *testing.T, metadataProtection bool, request *http.Request, callback func(rr *httptest.ResponseRecorder)) {
kubernetesService := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/namespaces" {
responseJSON, err := json.Marshal(v1.NamespaceList{
TypeMeta: unversioned.TypeMeta{
Kind: "NamespaceList",
APIVersion: "v1",
},
Items: []v1.Namespace{},
})
if err != nil {
t.Errorf("Failed to marshal json: %v", err)
}

_, err = rw.Write(responseJSON)
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
} else if r.URL.Path == "/api/v1/pods" {
responseJSON, err := json.Marshal(v1.PodList{
TypeMeta: unversioned.TypeMeta{
Kind: "PodList",
APIVersion: "v1",
},
Items: []v1.Pod{
{
Status: v1.PodStatus{
PodIP: "10.0.0.1",
},
ObjectMeta: v1.ObjectMeta{
Annotations: map[string]string{
"iam.amazonaws.com/role": "my-role",
},
},
},
},
})
if err != nil {
t.Errorf("Failed to marshal json: %v", err)
}

_, err = rw.Write(responseJSON)
if err != nil {
t.Errorf("Failed to write response: %v", err)
}
}
}))
defer kubernetesService.Close()

metadataService := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/latest/meta-data/instance-id" {
rw.Write([]byte("instanceid"))
}
}))
defer metadataService.Close()

server := NewServer()
metadataURL, err := url.Parse(metadataService.URL)
if err != nil {
t.Errorf("Failed to parse metadata url: %v", err)
}
server.MetadataAddress = metadataURL.Host
server.MetadataProtection = metadataProtection

err = server.setup(kubernetesService.URL, "-", "", true)
if err != nil {
t.Errorf("Error starting server: %v", err)
}

rr := httptest.NewRecorder()
request.RemoteAddr = "10.0.0.1:80"
server.server.Handler.ServeHTTP(rr, request)
callback(rr)
}

func TestMetadataProtection(t *testing.T) {
t.Run("allows requests with any user-agent when metadata protection is disabled", func(tt *testing.T) {
request, err := http.NewRequest("GET", "http://127.0.0.1/latest/meta-data/iam/security-credentials", nil)
if err != nil {
tt.Errorf("Failed to make request: %v", err)
}

withServer(tt, false, request, func(rr *httptest.ResponseRecorder) {
if rr.Code != 200 {
tt.Errorf("Expected 200, got %d", rr.Code)
}

body, err := ioutil.ReadAll(rr.Body)
if err != nil {
tt.Errorf("Error reading body: %v", err)
}
if string(body) != "my-role" {
tt.Errorf("Got unexpected role")
}
})
})

t.Run("blocks requests with wrong user-agent when metadata protection is enabled", func(tt *testing.T) {
request, err := http.NewRequest("GET", "http://127.0.0.1/latest/meta-data/iam/security-credentials", nil)
if err != nil {
tt.Errorf("Failed to make request: %v", err)
}

withServer(tt, true, request, func(rr *httptest.ResponseRecorder) {
if rr.Code != 403 {
tt.Errorf("Expected 403, got %d", rr.Code)
}
})
})

t.Run("allows requests with the correct user-agent when metadata protection is enabled", func(tt *testing.T) {
request, err := http.NewRequest("GET", "http://127.0.0.1/latest/meta-data/iam/security-credentials", nil)
request.Header.Set("User-Agent", "aws-cli/1.0")
if err != nil {
tt.Errorf("Failed to make request: %v", err)
}

withServer(tt, true, request, func(rr *httptest.ResponseRecorder) {
if rr.Code != 200 {
tt.Errorf("Expected 200, got %d", rr.Code)
}

body, err := ioutil.ReadAll(rr.Body)
if err != nil {
tt.Errorf("Error reading body: %v", err)
}
if string(body) != "my-role" {
tt.Errorf("Got unexpected role")
}
})
})

t.Run("allows healthchecks with wrong user-agent when metadata protection is enabled", func(tt *testing.T) {
request, err := http.NewRequest("GET", "http://127.0.0.1/healthz", nil)
if err != nil {
tt.Errorf("Failed to make request: %v", err)
}

withServer(tt, true, request, func(rr *httptest.ResponseRecorder) {
if rr.Code != 200 {
tt.Errorf("Expected 200, got %d", rr.Code)
}

_, err := ioutil.ReadAll(rr.Body)
if err != nil {
tt.Errorf("Error reading body: %v", err)
}
})
})

}