diff --git a/config/localTemplate.go b/config/localTemplate.go index 9583a194cd..9d6a9688bd 100644 --- a/config/localTemplate.go +++ b/config/localTemplate.go @@ -167,6 +167,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"` diff --git a/config/local_defaults.go b/config/local_defaults.go index 57457531be..4c2c16a359 100644 --- a/config/local_defaults.go +++ b/config/local_defaults.go @@ -77,6 +77,7 @@ var defaultLocal = Local{ EnableP2P: false, EnableP2PHybridMode: false, EnablePingHandler: true, + EnablePrivateNetworkAccessHeader: false, EnableProcessBlockStats: false, EnableProfiler: false, EnableRequestLogger: false, diff --git a/daemon/algod/api/server/lib/middlewares/cors.go b/daemon/algod/api/server/lib/middlewares/cors.go index 89e88dabb6..c8b292703f 100644 --- a/daemon/algod/api/server/lib/middlewares/cors.go +++ b/daemon/algod/api/server/lib/middlewares/cors.go @@ -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) + } + } +} diff --git a/daemon/algod/api/server/lib/middlewares/cors_test.go b/daemon/algod/api/server/lib/middlewares/cors_test.go new file mode 100644 index 0000000000..032596cfb2 --- /dev/null +++ b/daemon/algod/api/server/lib/middlewares/cors_test.go @@ -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 . + +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")) + }) + } +} diff --git a/daemon/algod/api/server/router.go b/daemon/algod/api/server/router.go index 0b02bb8566..555f2813a1 100644 --- a/daemon/algod/api/server/router.go +++ b/daemon/algod/api/server/router.go @@ -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), ) diff --git a/daemon/kmd/api/api.go b/daemon/kmd/api/api.go index 084b6f882b..c33d5aa451 100644 --- a/daemon/kmd/api/api.go +++ b/daemon/kmd/api/api.go @@ -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 diff --git a/daemon/kmd/api/cors.go b/daemon/kmd/api/cors.go index 6ff8e38453..0384de5306 100644 --- a/daemon/kmd/api/cors.go +++ b/daemon/kmd/api/cors.go @@ -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) + }) + } +} diff --git a/daemon/kmd/config/config.go b/daemon/kmd/config/config.go index 5dce6ba660..468db8ec03 100644 --- a/daemon/kmd/config/config.go +++ b/daemon/kmd/config/config.go @@ -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 diff --git a/daemon/kmd/kmd.go b/daemon/kmd/kmd.go index 3d3ce3c92d..220dd8da5b 100644 --- a/daemon/kmd/kmd.go +++ b/daemon/kmd/kmd.go @@ -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, diff --git a/daemon/kmd/server/server.go b/daemon/kmd/server/server.go index 712583d47a..58638f0bee 100644 --- a/daemon/kmd/server/server.go +++ b/daemon/kmd/server/server.go @@ -54,6 +54,7 @@ type WalletServerConfig struct { DataDir string Address string AllowedOrigins []string + AllowHeaderPNA bool SessionManager *session.Manager Log logging.Logger Timeout *time.Duration @@ -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 diff --git a/installer/config.json.example b/installer/config.json.example index 3a9714bbfb..d68aec24eb 100644 --- a/installer/config.json.example +++ b/installer/config.json.example @@ -56,6 +56,7 @@ "EnableP2P": false, "EnableP2PHybridMode": false, "EnablePingHandler": true, + "EnablePrivateNetworkAccessHeader": false, "EnableProcessBlockStats": false, "EnableProfiler": false, "EnableRequestLogger": false, diff --git a/test/e2e-go/cli/goal/expect/corsTest.exp b/test/e2e-go/cli/goal/expect/corsTest.exp index 7691b740fa..81a496489f 100755 --- a/test/e2e-go/cli/goal/expect/corsTest.exp +++ b/test/e2e-go/cli/goal/expect/corsTest.exp @@ -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 @@ -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 goal kmd start -t 180 -d $TEST_PRIMARY_NODE_DIR set KMD_NET_ADDRESS [::AlgorandGoal::GetKMDNetworkAddress $TEST_PRIMARY_NODE_DIR] diff --git a/test/e2e-go/cli/goal/expect/goalExpectCommon.exp b/test/e2e-go/cli/goal/expect/goalExpectCommon.exp index 4728f445df..54eda50240 100644 --- a/test/e2e-go/cli/goal/expect/goalExpectCommon.exp +++ b/test/e2e-go/cli/goal/expect/goalExpectCommon.exp @@ -817,6 +817,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 { diff --git a/test/testdata/configs/config-v34.json b/test/testdata/configs/config-v34.json index 3a9714bbfb..d68aec24eb 100644 --- a/test/testdata/configs/config-v34.json +++ b/test/testdata/configs/config-v34.json @@ -56,6 +56,7 @@ "EnableP2P": false, "EnableP2PHybridMode": false, "EnablePingHandler": true, + "EnablePrivateNetworkAccessHeader": false, "EnableProcessBlockStats": false, "EnableProfiler": false, "EnableRequestLogger": false, diff --git a/test/testdata/nettemplates/TwoNodes50EachPNA.json b/test/testdata/nettemplates/TwoNodes50EachPNA.json new file mode 100644 index 0000000000..2777bab9d2 --- /dev/null +++ b/test/testdata/nettemplates/TwoNodes50EachPNA.json @@ -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 } + ] + } + ] +}