Skip to content

Commit

Permalink
api: Add support for Private Network Access header preflight requests (
Browse files Browse the repository at this point in the history
…#6089)

Co-authored-by: cce <[email protected]>
  • Loading branch information
nullun and cce authored Sep 19, 2024
1 parent 62eb3bf commit 2a02530
Show file tree
Hide file tree
Showing 15 changed files with 253 additions and 3 deletions.
3 changes: 3 additions & 0 deletions config/localTemplate.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ type Local struct {
// EndpointAddress configures the address the node listens to for REST API calls. Specify an IP and port or just port. For example, 127.0.0.1:0 will listen on a random port on the localhost (preferring 8080).
EndpointAddress string `version[0]:"127.0.0.1:0"`

// Respond to Private Network Access preflight requests sent to the node. Useful when a public website is trying to access a node that's hosted on a local network.
EnablePrivateNetworkAccessHeader bool `version[34]:"false"`

// RestReadTimeoutSeconds is passed to the API servers rest http.Server implementation.
RestReadTimeoutSeconds int `version[4]:"15"`

Expand Down
1 change: 1 addition & 0 deletions config/local_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ var defaultLocal = Local{
EnableP2P: false,
EnableP2PHybridMode: false,
EnablePingHandler: true,
EnablePrivateNetworkAccessHeader: false,
EnableProcessBlockStats: false,
EnableProfiler: false,
EnableRequestLogger: false,
Expand Down
13 changes: 13 additions & 0 deletions daemon/algod/api/server/lib/middlewares/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,16 @@ func MakeCORS(tokenHeader string) echo.MiddlewareFunc {
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions},
})
}

// MakePNA constructs the Private Network Access middleware function
func MakePNA() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
req := ctx.Request()
if req.Method == http.MethodOptions && req.Header.Get("Access-Control-Request-Private-Network") == "true" {
ctx.Response().Header().Set("Access-Control-Allow-Private-Network", "true")
}
return next(ctx)
}
}
}
148 changes: 148 additions & 0 deletions daemon/algod/api/server/lib/middlewares/cors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (C) 2019-2024 Algorand, Inc.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// go-algorand is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with go-algorand. If not, see <https://www.gnu.org/licenses/>.

package middlewares

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/algorand/go-algorand/test/partitiontest"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)

func TestMakeCORS(t *testing.T) {
partitiontest.PartitionTest(t)
e := echo.New()
tokenHeader := "X-Algo-API-Token"
corsMiddleware := MakeCORS(tokenHeader)

testCases := []struct {
name string
method string
headers map[string]string
expectedStatus int
expectedHeaders map[string]string
}{
{
name: "OPTIONS request",
method: http.MethodOptions,
headers: map[string]string{
"Origin": "http://algorand.com",
"Access-Control-Request-Headers": "Content-Type," + tokenHeader,
},
expectedStatus: http.StatusNoContent,
expectedHeaders: map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": tokenHeader + ",Content-Type",
},
},
{
name: "GET request",
method: http.MethodGet,
headers: map[string]string{
"Origin": "http://algorand.com",
},
expectedStatus: http.StatusOK,
expectedHeaders: map[string]string{
"Access-Control-Allow-Origin": "*",
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, "/health", nil)
for key, value := range tc.headers {
req.Header.Set(key, value)
}
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

handler := corsMiddleware(func(c echo.Context) error {
return c.NoContent(http.StatusOK)
})

err := handler(c)

assert.NoError(t, err)
assert.Equal(t, tc.expectedStatus, rec.Code)
for key, value := range tc.expectedHeaders {
assert.Equal(t, value, rec.Header().Get(key))
}
})
}
}

func TestMakePNA(t *testing.T) {
partitiontest.PartitionTest(t)
e := echo.New()
pnaMiddleware := MakePNA()

testCases := []struct {
name string
method string
headers map[string]string
expectedStatusCode int
expectedHeader string
}{
{
name: "OPTIONS request with PNA header",
method: http.MethodOptions,
headers: map[string]string{"Access-Control-Request-Private-Network": "true"},
expectedStatusCode: http.StatusOK,
expectedHeader: "true",
},
{
name: "OPTIONS request without PNA header",
method: http.MethodOptions,
headers: map[string]string{},
expectedStatusCode: http.StatusOK,
expectedHeader: "",
},
{
name: "GET request",
method: http.MethodGet,
headers: map[string]string{},
expectedStatusCode: http.StatusOK,
expectedHeader: "",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, "/", nil)
for key, value := range tc.headers {
req.Header.Set(key, value)
}
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

handler := pnaMiddleware(func(c echo.Context) error {
return c.NoContent(http.StatusOK)
})

err := handler(c)

assert.NoError(t, err)
assert.Equal(t, tc.expectedStatusCode, rec.Code)
assert.Equal(t, tc.expectedHeader, rec.Header().Get("Access-Control-Allow-Private-Network"))
})
}
}
6 changes: 6 additions & 0 deletions daemon/algod/api/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ func NewRouter(logger logging.Logger, node APINodeInterface, shutdown <-chan str
middleware.RemoveTrailingSlash())
e.Use(
middlewares.MakeLogger(logger),
)
// Optional middleware for Private Network Access Header (PNA). Must come before CORS middleware.
if node.Config().EnablePrivateNetworkAccessHeader {
e.Use(middlewares.MakePNA())
}
e.Use(
middlewares.MakeCORS(TokenHeader),
)

Expand Down
5 changes: 4 additions & 1 deletion daemon/kmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,13 @@ func SwaggerHandler(w http.ResponseWriter, r *http.Request) {

// Handler returns the root mux router for the kmd API. It sets up handlers on
// subrouters specific to each API version.
func Handler(sm *session.Manager, log logging.Logger, allowedOrigins []string, apiToken string, reqCB func()) *mux.Router {
func Handler(sm *session.Manager, log logging.Logger, allowedOrigins []string, apiToken string, pnaHeader bool, reqCB func()) *mux.Router {
rootRouter := mux.NewRouter()

// Send the appropriate CORS headers
if pnaHeader {
rootRouter.Use(AllowPNA())
}
rootRouter.Use(corsMiddleware(allowedOrigins))

// Handle OPTIONS requests
Expand Down
13 changes: 13 additions & 0 deletions daemon/kmd/api/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,16 @@ func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
})
}
}

// AllowPNA constructs the Private Network Access middleware function
func AllowPNA() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Private-Network") == "true" {
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}

next.ServeHTTP(w, r)
})
}
}
1 change: 1 addition & 0 deletions daemon/kmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type KMDConfig struct {
SessionLifetimeSecs uint64 `json:"session_lifetime_secs"`
Address string `json:"address"`
AllowedOrigins []string `json:"allowed_origins"`
AllowHeaderPNA bool `json:"allow_header_pna"`
}

// DriverConfig contains config info specific to each wallet driver
Expand Down
1 change: 1 addition & 0 deletions daemon/kmd/kmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func Start(startConfig StartConfig) (died chan error, sock string, err error) {
DataDir: startConfig.DataDir,
Address: kmdCfg.Address,
AllowedOrigins: kmdCfg.AllowedOrigins,
AllowHeaderPNA: kmdCfg.AllowHeaderPNA,
SessionManager: session.MakeManager(kmdCfg),
Log: startConfig.Log,
Timeout: startConfig.Timeout,
Expand Down
3 changes: 2 additions & 1 deletion daemon/kmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type WalletServerConfig struct {
DataDir string
Address string
AllowedOrigins []string
AllowHeaderPNA bool
SessionManager *session.Manager
Log logging.Logger
Timeout *time.Duration
Expand Down Expand Up @@ -211,7 +212,7 @@ func (ws *WalletServer) start(kill chan os.Signal) (died chan error, sock string
// Initialize HTTP server
watchdogCB := ws.makeWatchdogCallback(kill)
srv := http.Server{
Handler: api.Handler(ws.SessionManager, ws.Log, ws.AllowedOrigins, ws.APIToken, watchdogCB),
Handler: api.Handler(ws.SessionManager, ws.Log, ws.AllowedOrigins, ws.APIToken, ws.AllowHeaderPNA, watchdogCB),
}

// Read the kill channel and shut down the server gracefully
Expand Down
1 change: 1 addition & 0 deletions installer/config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"EnableP2P": false,
"EnableP2PHybridMode": false,
"EnablePingHandler": true,
"EnablePrivateNetworkAccessHeader": false,
"EnableProcessBlockStats": false,
"EnableProfiler": false,
"EnableRequestLogger": false,
Expand Down
6 changes: 5 additions & 1 deletion test/e2e-go/cli/goal/expect/corsTest.exp
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ if { [catch {
set TEST_ROOT_DIR $TEST_ALGO_DIR/root
set TEST_PRIMARY_NODE_DIR $TEST_ROOT_DIR/Primary/
set NETWORK_NAME test_net_expect_$TIME_STAMP
set NETWORK_TEMPLATE "$TEST_DATA_DIR/nettemplates/TwoNodes50Each.json"
set NETWORK_TEMPLATE "$TEST_DATA_DIR/nettemplates/TwoNodes50EachPNA.json"

# Create network
::AlgorandGoal::CreateNetwork $NETWORK_NAME $NETWORK_TEMPLATE $TEST_ALGO_DIR $TEST_ROOT_DIR
Expand All @@ -31,6 +31,10 @@ if { [catch {
set ALGOD_NET_ADDRESS [::AlgorandGoal::GetAlgodNetworkAddress $TEST_PRIMARY_NODE_DIR]
::AlgorandGoal::CheckNetworkAddressForCors $ALGOD_NET_ADDRESS

# Hit algod with a private network access preflight request and look for 200 OK
set ALGOD_NET_ADDRESS [::AlgorandGoal::GetAlgodNetworkAddress $TEST_PRIMARY_NODE_DIR]
::AlgorandGoal::CheckNetworkAddressForPNA $ALGOD_NET_ADDRESS

# Start kmd, then do the same CORS check as algod
exec -- cat "$TEST_PRIMARY_NODE_DIR/kmd-v0.5/kmd_config.json.example" | jq {. |= . + {"allowed_origins": ["http://algorand.com"]}} > "$TEST_PRIMARY_NODE_DIR/kmd-v0.5/kmd_config.json"
exec goal kmd start -t 180 -d $TEST_PRIMARY_NODE_DIR
Expand Down
17 changes: 17 additions & 0 deletions test/e2e-go/cli/goal/expect/goalExpectCommon.exp
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,23 @@ proc ::AlgorandGoal::CheckNetworkAddressForCors { NET_ADDRESS } {
}
}

# Use curl to check if a network address supports private network access
proc ::AlgorandGoal::CheckNetworkAddressForPNA { NET_ADDRESS } {
if { [ catch {
spawn curl -X OPTIONS -H "Access-Control-Request-Private-Network: true" --head $NET_ADDRESS
expect {
timeout { close; ::AlgorandGoal::Abort "Timeout failure in CheckNetworkAddressForPNA" }
"Access-Control-Allow-Private-Network" { puts "success" ; close }
eof {
return -code error "EOF without Access-Control-Allow-Private-Network"
}
close
}
} EXCEPTION ] } {
::AlgorandGoal::Abort "ERROR in CheckNetworkAddressForPNA: $EXCEPTION"
}
}

# Show the Ledger Supply
proc ::AlgorandGoal::GetLedgerSupply { TEST_PRIMARY_NODE_DIR } {
if { [ catch {
Expand Down
1 change: 1 addition & 0 deletions test/testdata/configs/config-v34.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"EnableP2P": false,
"EnableP2PHybridMode": false,
"EnablePingHandler": true,
"EnablePrivateNetworkAccessHeader": false,
"EnableProcessBlockStats": false,
"EnableProfiler": false,
"EnableRequestLogger": false,
Expand Down
37 changes: 37 additions & 0 deletions test/testdata/nettemplates/TwoNodes50EachPNA.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"Genesis": {
"NetworkName": "tbd",
"LastPartKeyRound": 3000,
"Wallets": [
{
"Name": "Wallet1",
"Stake": 50,
"Online": true
},
{
"Name": "Wallet2",
"Stake": 50,
"Online": true
}
]
},
"Nodes": [
{
"Name": "Primary",
"IsRelay": true,
"ConfigJSONOverride": "{\"EnablePrivateNetworkAccessHeader\":true}",
"Wallets": [
{ "Name": "Wallet1",
"ParticipationOnly": false }
]
},
{
"Name": "Node",
"ConfigJSONOverride": "{\"EnablePrivateNetworkAccessHeader\":true}",
"Wallets": [
{ "Name": "Wallet2",
"ParticipationOnly": false }
]
}
]
}

0 comments on commit 2a02530

Please sign in to comment.