Skip to content

Commit

Permalink
Merge pull request #136 from launchdarkly/eb/ch73791/secure-mode
Browse files Browse the repository at this point in the history
(v6 - #4) implement secure mode
  • Loading branch information
eli-darkly authored Jul 20, 2020
2 parents 73abc1f + 23d8d10 commit f642e05
Show file tree
Hide file tree
Showing 14 changed files with 213 additions and 43 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ Property in file | Environment var | Type | Description
`sdkKey` | `LD_ENV_MyEnvName` | String | Server-side SDK key for the environment. Required.
`mobileKey` | `LD_MOBILE_KEY_MyEnvName` | String | Mobile key for the environment. Required if you are proxying mobile SDK functionality.
`envId` | `LD_CLIENT_SIDE_ID_MyEnvName` | String | Client-side ID for the environment. Required if you are proxying client-side JavaScript-based SDK functionality.
`secureMode` | `LD_SECURE_MODE_MyEnvName` | Boolean | True if [secure mode](https://docs.launchdarkly.com/sdk/client-side/javascript#secure-mode) should be required for client-side JS SDK connections.
`prefix` | `LD_PREFIX_MyEnvName` | String | If using a Redis, Consul, or DynamoDB feature store, this string will be added to all database keys to distinguish them from any other environments that are using the database.
`tableName` | `LD_TABLE_NAME_MyEnvName` | String | If using DynamoDB, you can specify a different table for each environment. (Or, specify a single table in the `[DynamoDB]` section and use `prefix` to distinguish the environments.)
`allowedOrigin` | `LD_ALLOWED_ORIGIN_MyEnvName` | URI | If provided, adds CORS headers to prevent access from other domains. This variable can be provided multiple times per environment (if using the `LD_ALLOWED_ORIGIN_MyEnvName` variable, specify a comma-delimited list).
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ type EnvConfig struct {
Prefix string // used only if Redis, Consul, or DynamoDB is enabled
TableName string // used only if DynamoDB is enabled
AllowedOrigin []string
SecureMode bool
InsecureSkipVerify bool
LogLevel OptLogLevel
TTL OptDuration
Expand Down
1 change: 1 addition & 0 deletions config/config_from_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func LoadConfigFromEnvironment(c *Config, loggers ldlog.Loggers) error {
ec := EnvConfig{SDKKey: SDKKey(envKeys[envName])}
ec.MobileKey = MobileKey(maybeEnvStr("LD_MOBILE_KEY_"+envName, string(ec.MobileKey)))
ec.EnvID = EnvironmentID(maybeEnvStr("LD_CLIENT_SIDE_ID_"+envName, string(ec.EnvID)))
maybeSetFromEnvBool(&ec.SecureMode, "LD_SECURE_MODE_"+envName)
maybeSetFromEnv(&ec.Prefix, "LD_PREFIX_"+envName)
maybeSetFromEnv(&ec.TableName, "LD_TABLE_NAME_"+envName)
maybeSetFromEnvAny(&ec.TTL, "LD_TTL_"+envName, &errs)
Expand Down
3 changes: 3 additions & 0 deletions config/test_data_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func makeValidConfigAllBaseProperties() testDataValidConfig {
SDKKey: "krypton-sdk",
MobileKey: "krypton-mob",
EnvID: "krypton-env",
SecureMode: true,
Prefix: "krypton-",
TableName: "krypton-table",
AllowedOrigin: []string{"https://oa", "https://rann"},
Expand Down Expand Up @@ -125,6 +126,7 @@ func makeValidConfigAllBaseProperties() testDataValidConfig {
"LD_ENV_krypton": "krypton-sdk",
"LD_MOBILE_KEY_krypton": "krypton-mob",
"LD_CLIENT_SIDE_ID_krypton": "krypton-env",
"LD_SECURE_MODE_krypton": "1",
"LD_PREFIX_krypton": "krypton-",
"LD_TABLE_NAME_krypton": "krypton-table",
"LD_ALLOWED_ORIGIN_krypton": "https://oa,https://rann",
Expand Down Expand Up @@ -164,6 +166,7 @@ LogLevel = "debug"
SdkKey = "krypton-sdk"
MobileKey = "krypton-mob"
EnvId = "krypton-env"
SecureMode = true
Prefix = "krypton-"
TableName = "krypton-table"
AllowedOrigin = "https://oa"
Expand Down
87 changes: 65 additions & 22 deletions endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,52 @@ import (
"gopkg.in/launchdarkly/go-server-sdk.v5/ldcomponents/ldstoreimpl"
)

func getClientSideUserProperties(
clientCtx relayenv.EnvContext,
sdkKind sdkKind,
req *http.Request,
w http.ResponseWriter,
) (lduser.User, bool) {
var user lduser.User
var userDecodeErr error

if req.Method == "REPORT" {
if req.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusUnsupportedMediaType)
w.Write([]byte("Content-Type must be application/json."))
return user, false
}
body, _ := ioutil.ReadAll(req.Body)
userDecodeErr = json.Unmarshal(body, &user)
} else {
base64User := mux.Vars(req)["user"]
user, userDecodeErr = UserV2FromBase64(base64User)
}
if userDecodeErr != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write(util.ErrorJsonMsg(userDecodeErr.Error()))
return user, false
}

if clientCtx.IsSecureMode() && sdkKind == jsClientSdk {
hash := req.URL.Query().Get("h")
valid := false
if hash != "" {
validHash := clientCtx.GetClient().SecureModeHash(user)
valid = hash == validHash
}
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write(util.ErrorJsonMsg("Environment is in secure mode, and user hash does not match."))
return user, false
}
}

return user, true
}

// Old stream endpoint that just sends "ping" events: clientstream.ld.com/mping (mobile)
// or clientstream.ld.com/ping/{envId} (JS)
func pingStreamHandler() http.Handler {
Expand All @@ -36,6 +82,19 @@ func pingStreamHandler() http.Handler {
})
}

// This handler is used for client-side streaming endpoints that require user properties. Currently it is
// implemented the same as the ping stream once we have validated the user.
func pingStreamHandlerWithUser(sdkKind sdkKind) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
clientCtx := getClientContext(req)
clientCtx.GetLoggers().Debug("Application requested client-side ping stream")

if _, ok := getClientSideUserProperties(clientCtx, sdkKind, req, w); ok {
clientCtx.GetHandlers().PingStreamHandler.ServeHTTP(w, req)
}
})
}

// Server-side SDK streaming endpoint for both flags and segments: stream.ld.com/all
func allStreamHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
Expand Down Expand Up @@ -136,34 +195,18 @@ func evaluateAllFeatureFlagsValueOnly(sdkKind sdkKind) func(w http.ResponseWrite
}

func evaluateAllShared(w http.ResponseWriter, req *http.Request, valueOnly bool, sdkKind sdkKind) {
var user lduser.User
var userDecodeErr error
if req.Method == "REPORT" {
if req.Header.Get("Content-Type") != "application/json" {
w.WriteHeader(http.StatusUnsupportedMediaType)
w.Write([]byte("Content-Type must be application/json."))
return
}
clientCtx := getClientContext(req)
client := clientCtx.GetClient()
store := clientCtx.GetStore()
loggers := clientCtx.GetLoggers()

body, _ := ioutil.ReadAll(req.Body)
userDecodeErr = json.Unmarshal(body, &user)
} else {
base64User := mux.Vars(req)["user"]
user, userDecodeErr = UserV2FromBase64(base64User)
}
if userDecodeErr != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write(util.ErrorJsonMsg(userDecodeErr.Error()))
user, ok := getClientSideUserProperties(clientCtx, sdkKind, req, w)
if !ok {
return
}

withReasons := req.URL.Query().Get("withReasons") == "true"

clientCtx := getClientContext(req)
client := clientCtx.GetClient()
store := clientCtx.GetStore()
loggers := clientCtx.GetLoggers()

w.Header().Set("Content-Type", "application/json")

if !client.Initialized() {
Expand Down
4 changes: 4 additions & 0 deletions internal/relayenv/env_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ type EnvContext interface {

// GetInitError returns an error if initialization has failed, or nil otherwise.
GetInitError() error

// IsSecureMode returns true if client-side evaluation requests for this environment must have a valid
// secure mode hash.
IsSecureMode() bool
}

// Credentials encapsulates all the configured LD credentials for an environment. The SDK key is mandatory;
Expand Down
6 changes: 6 additions & 0 deletions internal/relayenv/env_context_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type envContextImpl struct {
handlers ClientHandlers
credentials Credentials
name string
secureMode bool
metricsEnv *metrics.EnvironmentManager
ttl time.Duration
initErr error
Expand Down Expand Up @@ -114,6 +115,7 @@ func NewEnvContext(
},
storeAdapter: storeAdapter,
loggers: envLoggers,
secureMode: envConfig.SecureMode,
metricsEnv: em,
ttl: envConfig.TTL.GetOrElse(0),
handlers: ClientHandlers{
Expand Down Expand Up @@ -204,3 +206,7 @@ func (c *envContextImpl) GetTTL() time.Duration {
func (c *envContextImpl) GetInitError() error {
return c.initErr
}

func (c *envContextImpl) IsSecureMode() bool {
return c.secureMode
}
8 changes: 4 additions & 4 deletions relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,8 @@ func (r *Relay) makeHandler(withRequestLogging bool) http.Handler {

mobileStreamRouter := router.PathPrefix("/meval").Subrouter()
mobileStreamRouter.Use(mobileMiddlewareStack, streamingMiddleware)
mobileStreamRouter.Handle("", countMobileConns(pingStreamHandler())).Methods("REPORT")
mobileStreamRouter.Handle("/{user}", countMobileConns(pingStreamHandler())).Methods("GET")
mobileStreamRouter.Handle("", countMobileConns(pingStreamHandlerWithUser(mobileSdk))).Methods("REPORT")
mobileStreamRouter.Handle("/{user}", countMobileConns(pingStreamHandlerWithUser(mobileSdk))).Methods("GET")

router.Handle("/mping", r.mobileClientMux.selectClientByAuthorizationKey(mobileSdk)(
countMobileConns(streamingMiddleware(pingStreamHandler())))).Methods("GET")
Expand All @@ -303,8 +303,8 @@ func (r *Relay) makeHandler(withRequestLogging bool) http.Handler {
clientSideStreamEvalRouter := router.PathPrefix("/eval/{envId}").Subrouter()
clientSideStreamEvalRouter.Use(clientSideMiddlewareStack, mux.CORSMethodMiddleware(clientSideStreamEvalRouter), streamingMiddleware)
// For now we implement eval as simply ping
clientSideStreamEvalRouter.Handle("/{user}", countBrowserConns(pingStreamHandler())).Methods("GET", "OPTIONS")
clientSideStreamEvalRouter.Handle("", countBrowserConns(pingStreamHandler())).Methods("REPORT", "OPTIONS")
clientSideStreamEvalRouter.Handle("/{user}", countBrowserConns(pingStreamHandlerWithUser(jsClientSdk))).Methods("GET", "OPTIONS")
clientSideStreamEvalRouter.Handle("", countBrowserConns(pingStreamHandlerWithUser(jsClientSdk))).Methods("REPORT", "OPTIONS")

mobileEventsRouter := router.PathPrefix("/mobile").Subrouter()
mobileEventsRouter.Use(mobileMiddlewareStack)
Expand Down
40 changes: 38 additions & 2 deletions relay_endpoints_eval_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package relay

import (
"encoding/json"
"net/http"
"testing"

"github.com/stretchr/testify/assert"

"gopkg.in/launchdarkly/go-sdk-common.v2/lduser"

c "github.com/launchdarkly/ld-relay/v6/config"
)

Expand Down Expand Up @@ -125,7 +128,8 @@ func TestRelayMobileEvalRoutes(t *testing.T) {
func TestRelayJSClientEvalRoutes(t *testing.T) {
env := testEnvClientSide
envID := env.config.EnvID
userJSON := []byte(`{"key":"me"}`)
user := lduser.NewUser("me")
userJSON, _ := json.Marshal(user)
expectedJSEvalBody := expectJSONBody(makeEvalBody(clientSideFlags, false, false))
expectedJSEvalxBody := expectJSONBody(makeEvalBody(clientSideFlags, true, false))
expectedJSEvalxBodyWithReasons := expectJSONBody(makeEvalBody(clientSideFlags, true, true))
Expand All @@ -142,7 +146,7 @@ func TestRelayJSClientEvalRoutes(t *testing.T) {
}

config := c.DefaultConfig
config.Environment = makeEnvConfigs(env)
config.Environment = makeEnvConfigs(testEnvClientSide, testEnvClientSideSecureMode)

relayTest(config, func(p relayTestParams) {
for _, s := range specs {
Expand All @@ -159,6 +163,38 @@ func TestRelayJSClientEvalRoutes(t *testing.T) {
}
})

t.Run("secure mode - hash matches", func(t *testing.T) {
s1 := s
s1.credential = testEnvClientSideSecureMode.config.EnvID
s1.path = addQueryParam(s1.path, "h="+fakeHashForUser(user))
result, body := doRequest(s1.request(), p.relay)

if assert.Equal(t, s.expectedStatus, result.StatusCode) {
assertNonStreamingHeaders(t, result.Header)
assertExpectedCORSHeaders(t, result, s.method, "*")
if s.bodyMatcher != nil {
s.bodyMatcher(t, body)
}
}
})

t.Run("secure mode - hash does not match", func(t *testing.T) {
s1 := s
s1.credential = testEnvClientSideSecureMode.config.EnvID
s1.path = addQueryParam(s1.path, "h=incorrect")
result, _ := doRequest(s1.request(), p.relay)

assert.Equal(t, http.StatusBadRequest, result.StatusCode)
})

t.Run("secure mode - hash not provided", func(t *testing.T) {
s1 := s
s1.credential = testEnvClientSideSecureMode.config.EnvID
result, _ := doRequest(s1.request(), p.relay)

assert.Equal(t, http.StatusBadRequest, result.StatusCode)
})

t.Run("unknown environment ID", func(t *testing.T) {
s1 := s
s1.credential = undefinedEnvID
Expand Down
53 changes: 46 additions & 7 deletions relay_endpoints_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (

"github.com/stretchr/testify/assert"

"gopkg.in/launchdarkly/go-sdk-common.v2/lduser"

"github.com/launchdarkly/eventsource"
c "github.com/launchdarkly/ld-relay/v6/config"
)
Expand All @@ -18,8 +20,8 @@ type streamEndpointTestParams struct {
expectedData []byte
}

func (s streamEndpointTestParams) assertRequestReceivesEvent(t *testing.T, r *http.Request, relay *Relay) *http.Response {
return withStreamRequest(t, r, relay, func(eventCh <-chan eventsource.Event) {
func (s streamEndpointTestParams) assertRequestReceivesEvent(t *testing.T, relay *Relay) *http.Response {
return withStreamRequest(t, s.request(), relay, func(eventCh <-chan eventsource.Event) {
select {
case event := <-eventCh:
if event != nil {
Expand Down Expand Up @@ -59,7 +61,7 @@ func TestRelayServerSideStreams(t *testing.T) {
for _, s := range specs {
t.Run(s.name, func(t *testing.T) {
t.Run("success", func(t *testing.T) {
s.assertRequestReceivesEvent(t, s.request(), p.relay)
s.assertRequestReceivesEvent(t, p.relay)
})

t.Run("unknown SDK key", func(t *testing.T) {
Expand Down Expand Up @@ -95,7 +97,7 @@ func TestRelayMobileStreams(t *testing.T) {
for _, s := range specs {
t.Run(s.name, func(t *testing.T) {
t.Run("success", func(t *testing.T) {
s.assertRequestReceivesEvent(t, s.request(), p.relay)
s.assertRequestReceivesEvent(t, p.relay)
})

t.Run("unknown mobile key", func(t *testing.T) {
Expand All @@ -113,7 +115,8 @@ func TestRelayMobileStreams(t *testing.T) {
func TestRelayJSClientStreams(t *testing.T) {
env := testEnvClientSide
envID := env.config.EnvID
userJSON := []byte(`{"key":"me"}`)
user := lduser.NewUser("me")
userJSON, _ := json.Marshal(user)

specs := []streamEndpointTestParams{
{endpointTestParams{"client-side get ping", "GET", "/ping/$ENV", nil, envID, 200, nil},
Expand All @@ -125,18 +128,54 @@ func TestRelayJSClientStreams(t *testing.T) {
}

config := c.DefaultConfig
config.Environment = makeEnvConfigs(env)
config.Environment = makeEnvConfigs(testEnvClientSide, testEnvClientSideSecureMode)

relayTest(config, func(p relayTestParams) {
for _, s := range specs {
t.Run(s.name, func(t *testing.T) {
t.Run("requests", func(t *testing.T) {
result := s.assertRequestReceivesEvent(t, s.request(), p.relay)
result := s.assertRequestReceivesEvent(t, p.relay)

assertStreamingHeaders(t, result.Header)
assertExpectedCORSHeaders(t, result, s.method, "*")
})

if s.data != nil {
t.Run("secure mode - hash matches", func(t *testing.T) {
s1 := s
s1.credential = testEnvClientSideSecureMode.config.EnvID
s1.path = addQueryParam(s1.path, "h="+fakeHashForUser(user))
result := s1.assertRequestReceivesEvent(t, p.relay)

assertStreamingHeaders(t, result.Header)
assertExpectedCORSHeaders(t, result, s.method, "*")
})

t.Run("secure mode - hash does not match", func(t *testing.T) {
s1 := s
s1.credential = testEnvClientSideSecureMode.config.EnvID
s1.path = addQueryParam(s1.path, "h=incorrect")
result := doStreamRequestExpectingError(s1.request(), p.relay)

assert.Equal(t, http.StatusBadRequest, result.StatusCode)
})

t.Run("secure mode - hash not provided", func(t *testing.T) {
s1 := s
s1.credential = testEnvClientSideSecureMode.config.EnvID
result := doStreamRequestExpectingError(s1.request(), p.relay)

assert.Equal(t, http.StatusBadRequest, result.StatusCode)
})
}

t.Run("unknown environment ID", func(t *testing.T) {
s1 := s
s1.credential = undefinedEnvID
result, _ := doRequest(s1.request(), p.relay)
assert.Equal(t, http.StatusNotFound, result.StatusCode)
})

t.Run("options", func(t *testing.T) {
assertEndpointSupportsOptionsRequest(t, p.relay, s.localURL(), s.method)
})
Expand Down
Loading

0 comments on commit f642e05

Please sign in to comment.