Skip to content

Commit

Permalink
Merge adb290e into d80f3ee
Browse files Browse the repository at this point in the history
  • Loading branch information
hsanjuan committed Jan 11, 2019
2 parents d80f3ee + adb290e commit fdbb491
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 15 deletions.
81 changes: 75 additions & 6 deletions api/rest/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"time"

"github.com/ipfs/ipfs-cluster/config"
"github.com/rs/cors"

"github.com/kelseyhightower/envconfig"

Expand All @@ -32,11 +34,26 @@ const (

// These are the default values for Config.
var (
DefaultHeaders = map[string][]string{
"Access-Control-Allow-Headers": []string{"X-Requested-With", "Range"},
"Access-Control-Allow-Methods": []string{"GET"},
"Access-Control-Allow-Origin": []string{"*"},
DefaultHeaders = map[string][]string{}
)

// CORS defaults
var (
DefaultCORSAllowedOrigins = []string{"*"}
DefaultCORSAllowedMethods = []string{
http.MethodGet,
}
// rs/cors this will set sensible defaults when empty:
// {"Origin", "Accept", "Content-Type", "X-Requested-With"}
DefaultCORSAllowedHeaders = []string{}
DefaultCORSExposedHeaders = []string{
"Content-Type",
"X-Stream-Output",
"X-Chunked-Output",
"X-Content-Length",
}
DefaultCORSAllowCredentials = true
DefaultCORSMaxAge time.Duration = 0
)

// Config is used to intialize the API object and allows to
Expand Down Expand Up @@ -85,8 +102,16 @@ type Config struct {
BasicAuthCreds map[string]string

// Headers provides customization for the headers returned
// by the API. By default it sets a CORS policy.
// by the API on existing routes.
Headers map[string][]string

// CORS header management
CORSAllowedOrigins []string
CORSAllowedMethods []string
CORSAllowedHeaders []string
CORSExposedHeaders []string
CORSAllowCredentials bool
CORSMaxAge time.Duration
}

type jsonConfig struct {
Expand All @@ -105,6 +130,13 @@ type jsonConfig struct {

BasicAuthCreds map[string]string `json:"basic_auth_credentials"`
Headers map[string][]string `json:"headers"`

CORSAllowedOrigins []string `json:"cors_allowed_origins"`
CORSAllowedMethods []string `json:"cors_allowed_methods"`
CORSAllowedHeaders []string `json:"cors_allowed_headers"`
CORSExposedHeaders []string `json:"cors_exposed_headers"`
CORSAllowCredentials bool `json:"cors_allow_credentials"`
CORSMaxAge string `json:"cors_max_age"`
}

// ConfigKey returns a human-friendly identifier for this type of
Expand Down Expand Up @@ -136,6 +168,13 @@ func (cfg *Config) Default() error {
// Headers
cfg.Headers = DefaultHeaders

cfg.CORSAllowedOrigins = DefaultCORSAllowedOrigins
cfg.CORSAllowedMethods = DefaultCORSAllowedMethods
cfg.CORSAllowedHeaders = DefaultCORSAllowedHeaders
cfg.CORSExposedHeaders = DefaultCORSExposedHeaders
cfg.CORSAllowCredentials = DefaultCORSAllowCredentials
cfg.CORSMaxAge = DefaultCORSMaxAge

return nil
}

Expand All @@ -154,7 +193,9 @@ func (cfg *Config) Validate() error {
case cfg.BasicAuthCreds != nil && len(cfg.BasicAuthCreds) == 0:
return errors.New("restapi.basic_auth_creds should be null or have at least one entry")
case (cfg.pathSSLCertFile != "" || cfg.pathSSLKeyFile != "") && cfg.TLS == nil:
return errors.New("missing TLS configuration")
return errors.New("restapi: missing TLS configuration")
case (cfg.CORSMaxAge < 0):
return errors.New("restapi.cors_max_age is invalid")
}

return cfg.validateLibp2p()
Expand Down Expand Up @@ -232,12 +273,20 @@ func (cfg *Config) loadHTTPOptions(jcfg *jsonConfig) error {
return err
}

// CORS
cfg.CORSAllowedOrigins = jcfg.CORSAllowedOrigins
cfg.CORSAllowedMethods = jcfg.CORSAllowedMethods
cfg.CORSAllowedHeaders = jcfg.CORSAllowedHeaders
cfg.CORSExposedHeaders = jcfg.CORSExposedHeaders
cfg.CORSAllowCredentials = jcfg.CORSAllowCredentials

return config.ParseDurations(
"restapi",
&config.DurationOpt{Duration: jcfg.ReadTimeout, Dst: &cfg.ReadTimeout, Name: "read_timeout"},
&config.DurationOpt{Duration: jcfg.ReadHeaderTimeout, Dst: &cfg.ReadHeaderTimeout, Name: "read_header_timeout"},
&config.DurationOpt{Duration: jcfg.WriteTimeout, Dst: &cfg.WriteTimeout, Name: "write_timeout"},
&config.DurationOpt{Duration: jcfg.IdleTimeout, Dst: &cfg.IdleTimeout, Name: "idle_timeout"},
&config.DurationOpt{Duration: jcfg.CORSMaxAge, Dst: &cfg.CORSMaxAge, Name: "cors_max_age"},
)
}

Expand Down Expand Up @@ -323,6 +372,12 @@ func (cfg *Config) ToJSON() (raw []byte, err error) {
IdleTimeout: cfg.IdleTimeout.String(),
BasicAuthCreds: cfg.BasicAuthCreds,
Headers: cfg.Headers,
CORSAllowedOrigins: cfg.CORSAllowedOrigins,
CORSAllowedMethods: cfg.CORSAllowedMethods,
CORSAllowedHeaders: cfg.CORSAllowedHeaders,
CORSExposedHeaders: cfg.CORSExposedHeaders,
CORSAllowCredentials: cfg.CORSAllowCredentials,
CORSMaxAge: cfg.CORSMaxAge.String(),
}

if cfg.ID != "" {
Expand All @@ -343,6 +398,20 @@ func (cfg *Config) ToJSON() (raw []byte, err error) {
return
}

func (cfg *Config) corsOptions() *cors.Options {
maxAgeSeconds := int(cfg.CORSMaxAge / time.Second)

return &cors.Options{
AllowedOrigins: cfg.CORSAllowedOrigins,
AllowedMethods: cfg.CORSAllowedMethods,
AllowedHeaders: cfg.CORSAllowedHeaders,
ExposedHeaders: cfg.CORSExposedHeaders,
AllowCredentials: cfg.CORSAllowCredentials,
MaxAge: maxAgeSeconds,
Debug: false,
}
}

func newTLSConfig(certFile, keyFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
Expand Down
10 changes: 8 additions & 2 deletions api/rest/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ import (

var cfgJSON = []byte(`
{
"listen_multiaddress": "/ip4/127.0.0.1/tcp/9094",
"listen_multiaddress": "/ip4/127.0.0.1/tcp/12122",
"ssl_cert_file": "test/server.crt",
"ssl_key_file": "test/server.key",
"read_timeout": "30s",
"read_header_timeout": "5s",
"write_timeout": "1m0s",
"idle_timeout": "2m0s",
"basic_auth_credentials": null
"basic_auth_credentials": null,
"cors_allowed_origins": ["myorigin"],
"cors_allowed_methods": ["GET"],
"cors_allowed_headers": ["X-Custom"],
"cors_exposed_headers": ["X-Chunked-Output"],
"cors_allow_credentials": false,
"cors_max_age": "1s"
}
`)

Expand Down
5 changes: 4 additions & 1 deletion api/rest/restapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"github.com/ipfs/ipfs-cluster/adder/adderutils"
types "github.com/ipfs/ipfs-cluster/api"
"github.com/rs/cors"

mux "github.com/gorilla/mux"
gostream "github.com/hsanjuan/go-libp2p-gostream"
Expand Down Expand Up @@ -109,12 +110,14 @@ func NewAPIWithHost(cfg *Config, h host.Host) (*API, error) {
}

router := mux.NewRouter().StrictSlash(true)
c := cors.New(*cfg.corsOptions())
withCorsRouter := c.Handler(router)
s := &http.Server{
ReadTimeout: cfg.ReadTimeout,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
Handler: router,
Handler: withCorsRouter,
}

// See: https://github.com/ipfs/go-ipfs/issues/5168
Expand Down
94 changes: 88 additions & 6 deletions api/rest/restapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/http"
"strings"
"testing"
"time"

"github.com/ipfs/ipfs-cluster/api"
"github.com/ipfs/ipfs-cluster/test"
Expand All @@ -25,8 +26,9 @@ import (
)

const (
SSLCertFile = "test/server.crt"
SSLKeyFile = "test/server.key"
SSLCertFile = "test/server.crt"
SSLKeyFile = "test/server.key"
clientOrigin = "myorigin"
)

func testAPI(t *testing.T) *API {
Expand All @@ -39,6 +41,10 @@ func testAPI(t *testing.T) *API {
cfg := &Config{}
cfg.Default()
cfg.HTTPListenAddr = apiMAddr
cfg.CORSAllowedOrigins = []string{clientOrigin}
cfg.CORSAllowedMethods = []string{"GET", "POST", "DELETE"}
//cfg.CORSAllowedHeaders = []string{"Content-Type"}
cfg.CORSMaxAge = 10 * time.Minute

rest, err := NewAPIWithHost(cfg, h)
if err != nil {
Expand Down Expand Up @@ -133,6 +139,10 @@ func checkHeaders(t *testing.T, rest *API, url string, headers http.Header) {
if headers.Get("Content-Type") != "application/json" {
t.Errorf("%s is not application/json", url)
}

if eh := headers.Get("Access-Control-Expose-Headers"); eh == "" {
t.Error("AC-Expose-Headers not set")
}
}

// makes a libp2p host that knows how to talk to the rest API host.
Expand Down Expand Up @@ -194,7 +204,9 @@ func makeGet(t *testing.T, rest *API, url string, resp interface{}) {
h := makeHost(t, rest)
defer h.Close()
c := httpClient(t, h, isHTTPS(url))
httpResp, err := c.Get(url)
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Origin", clientOrigin)
httpResp, err := c.Do(req)
processResp(t, httpResp, err, resp)
checkHeaders(t, rest, url, httpResp.Header)
}
Expand All @@ -207,7 +219,10 @@ func makePostWithContentType(t *testing.T, rest *API, url string, body []byte, c
h := makeHost(t, rest)
defer h.Close()
c := httpClient(t, h, isHTTPS(url))
httpResp, err := c.Post(url, contentType, bytes.NewReader(body))
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", contentType)
req.Header.Set("Origin", clientOrigin)
httpResp, err := c.Do(req)
processResp(t, httpResp, err, resp)
checkHeaders(t, rest, url, httpResp.Header)
}
Expand All @@ -216,17 +231,32 @@ func makeDelete(t *testing.T, rest *API, url string, resp interface{}) {
h := makeHost(t, rest)
defer h.Close()
c := httpClient(t, h, isHTTPS(url))
req, _ := http.NewRequest("DELETE", url, bytes.NewReader([]byte{}))
req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewReader([]byte{}))
req.Header.Set("Origin", clientOrigin)
httpResp, err := c.Do(req)
processResp(t, httpResp, err, resp)
checkHeaders(t, rest, url, httpResp.Header)
}

func makeOptions(t *testing.T, rest *API, url string, reqHeaders http.Header) http.Header {
h := makeHost(t, rest)
defer h.Close()
c := httpClient(t, h, isHTTPS(url))
req, _ := http.NewRequest(http.MethodOptions, url, nil)
req.Header = reqHeaders
httpResp, err := c.Do(req)
processResp(t, httpResp, err, nil)
return httpResp.Header
}

func makeStreamingPost(t *testing.T, rest *API, url string, body io.Reader, contentType string, resp interface{}) {
h := makeHost(t, rest)
defer h.Close()
c := httpClient(t, h, isHTTPS(url))
httpResp, err := c.Post(url, contentType, body)
req, _ := http.NewRequest(http.MethodPost, url, body)
req.Header.Set("Content-Type", contentType)
req.Header.Set("Origin", clientOrigin)
httpResp, err := c.Do(req)
processStreamingResp(t, httpResp, err, resp)
checkHeaders(t, rest, url, httpResp.Header)
}
Expand Down Expand Up @@ -825,3 +855,55 @@ func TestAPIRecoverAllEndpoint(t *testing.T) {

testBothEndpoints(t, tf)
}

func TestCORS(t *testing.T) {
rest := testAPI(t)
defer rest.Shutdown()

type testcase struct {
method string
path string
}

tf := func(t *testing.T, url urlF) {
reqHeaders := make(http.Header)
reqHeaders.Set("Origin", "myorigin")
reqHeaders.Set("Access-Control-Request-Headers", "Content-Type")

for _, tc := range []testcase{
testcase{"GET", "/pins"},
// testcase{},
} {
reqHeaders.Set("Access-Control-Request-Method", tc.method)
headers := makeOptions(t, rest, url(rest)+tc.path, reqHeaders)
aorigin := headers.Get("Access-Control-Allow-Origin")
amethods := headers.Get("Access-Control-Allow-Methods")
aheaders := headers.Get("Access-Control-Allow-Headers")
acreds := headers.Get("Access-Control-Allow-Credentials")
maxage := headers.Get("Access-Control-Max-Age")

if aorigin != "myorigin" {
t.Error("Bad ACA-Origin:", aorigin)
}

if amethods != tc.method {
t.Error("Bad ACA-Methods:", amethods)
}

if aheaders != "Content-Type" {
t.Error("Bad ACA-Headers:", aheaders)
}

if acreds != "true" {
t.Error("Bad ACA-Credentials:", acreds)
}

if maxage != "600" {
t.Error("Bad AC-Max-Age:", maxage)
}
}

}

testBothEndpoints(t, tf)
}
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@
"hash": "QmeP7Gybon3hs9KhoxSFvzqAHQS6xgyKYvsnjqktaXX3QN",
"name": "go-libp2p-pubsub",
"version": "100.11.9"
},
{
"author": "hsanjuan",
"hash": "QmNNk4iczWp8Q4R1mXQ2mrrjQvWisYqMqbW1an8qGbJZsM",
"name": "cors",
"version": "1.6.0"
}
],
"gxVersion": "0.11.0",
Expand Down

0 comments on commit fdbb491

Please sign in to comment.