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

api: Add support for Private Network Access header preflight requests #6089

Merged
merged 10 commits into from
Sep 19, 2024
3 changes: 3 additions & 0 deletions config/localTemplate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
1 change: 1 addition & 0 deletions config/local_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,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 @@
middleware.RemoveTrailingSlash())
e.Use(
middlewares.MakeLogger(logger),
)

Check warning on line 110 in daemon/algod/api/server/router.go

View check run for this annotation

Codecov / codecov/patch

daemon/algod/api/server/router.go#L110

Added line #L110 was not covered by tests
// Optional middleware for Private Network Access Header (PNA). Must come before CORS middleware.
if node.Config().EnablePrivateNetworkAccessHeader {
e.Use(middlewares.MakePNA())

Check warning on line 113 in daemon/algod/api/server/router.go

View check run for this annotation

Codecov / codecov/patch

daemon/algod/api/server/router.go#L112-L113

Added lines #L112 - L113 were not covered by tests
}
e.Use(

Check warning on line 115 in daemon/algod/api/server/router.go

View check run for this annotation

Codecov / codecov/patch

daemon/algod/api/server/router.go#L115

Added line #L115 was not covered by tests
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())
gmalouf marked this conversation as resolved.
Show resolved Hide resolved
}
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 @@ -56,6 +56,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
gmalouf marked this conversation as resolved.
Show resolved Hide resolved
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]
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 @@ -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 {
algorandskiy marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -56,6 +56,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 }
]
}
]
}
Loading