Repository: smartcontractkit/chainlink
Pull request #17114: [DEVSVCS-1559] CORS support for Testnet Gateways
Author: login: ibrajer
Labels: name: runs-on
description: opt-in to self-hosted runners for certain jobs
Created: 2025-04-04T12:41:57Z | Merged: 2025-04-10T14:08:31Z

Ticket: https://smartcontract-it.atlassian.net/browse/DEVSVCS-1559

<!--- Does this work have a corresponding ticket?  Please link it here as well as one of:
    - the PR title
    - branch name
    - commit message
[LINK-777](https://smartcontract-it.atlassian.net/browse/LINK-777)
--> 

### Requires
<!--- Does this work depend on other open PRs? Please list them.
- https://github.com/smartcontractkit/chainlink-common/pull/7777777
-->

### Supports
<!--- Does this work support other open PRs?  Please list them.
- https://github.com/smartcontractkit/ccip/pull/7777777
-->



Diff:
diff --git a/.changeset/ripe-mice-drop.md b/.changeset/ripe-mice-drop.md
new file mode 100644
index 00000000000..b25ae48bf7d
--- /dev/null
+++ b/.changeset/ripe-mice-drop.md
@@ -0,0 +1,5 @@
+---
+"chainlink": patch
+---
+
+CORS header support to Gateway node #changed
diff --git a/core/scripts/gateway/sample_config.toml b/core/scripts/gateway/sample_config.toml
index c58e2564733..2d2e627d0ee 100644
--- a/core/scripts/gateway/sample_config.toml
+++ b/core/scripts/gateway/sample_config.toml
@@ -6,6 +6,8 @@ ReadTimeoutMillis = 1000
 WriteTimeoutMillis = 1000
 RequestTimeoutMillis = 1000
 MaxRequestBytes = 10_000
+CORSEnabled = false
+CORSAllowedOrigins = []
 
 [NodeServerConfig]
 Port = 8081
@@ -27,4 +29,4 @@ HandlerName = "dummy"
 
 [[Dons.Members]]
 Name = "example_node"
-Address = "0x68902d681c28119f9b2531473a417088bf008e59"
\ No newline at end of file
+Address = "0x68902d681c28119f9b2531473a417088bf008e59"
diff --git a/core/scripts/gateway/sample_config_tls.toml b/core/scripts/gateway/sample_config_tls.toml
index a91d71a9132..a0faafa6c1e 100644
--- a/core/scripts/gateway/sample_config_tls.toml
+++ b/core/scripts/gateway/sample_config_tls.toml
@@ -9,6 +9,8 @@ ReadTimeoutMillis = 1000
 WriteTimeoutMillis = 1000
 RequestTimeoutMillis = 1000
 MaxRequestBytes = 10_000
+CORSEnabled = false
+CORSAllowedOrigins = []
 
 [NodeServerConfig]
 Port = 8089
@@ -33,4 +35,4 @@ HandlerName = "dummy"
 
 [[Dons.Members]]
 Name = "example_node"
-Address = "0x68902d681c28119f9b2531473a417088bf008e59"
\ No newline at end of file
+Address = "0x68902d681c28119f9b2531473a417088bf008e59"
diff --git a/core/services/gateway/integration_tests/gateway_integration_test.go b/core/services/gateway/integration_tests/gateway_integration_test.go
index 47489490258..0449b6c5c55 100644
--- a/core/services/gateway/integration_tests/gateway_integration_test.go
+++ b/core/services/gateway/integration_tests/gateway_integration_test.go
@@ -51,6 +51,8 @@ MaxRequestBytes = 20_000
 ReadTimeoutMillis = 1000
 RequestTimeoutMillis = 1000
 WriteTimeoutMillis = 1000
+CORSEnabled = true
+CORSAllowedOrigins = ["https://remix.ethereum.org"]
 
 [[Dons]]
 DonId = "test_don"
diff --git a/core/services/gateway/network/httpserver.go b/core/services/gateway/network/httpserver.go
index e7b622fc33c..5b97e0b2bee 100644
--- a/core/services/gateway/network/httpserver.go
+++ b/core/services/gateway/network/httpserver.go
@@ -6,6 +6,8 @@ import (
 	"io"
 	"net"
 	"net/http"
+	"net/url"
+	"strings"
 	"time"
 
 	"github.com/smartcontractkit/chainlink-common/pkg/services"
@@ -39,6 +41,8 @@ type HTTPServerConfig struct {
 	WriteTimeoutMillis   uint32
 	RequestTimeoutMillis uint32
 	MaxRequestBytes      int64
+	CORSEnabled          bool
+	CORSAllowedOrigins   []string
 }
 
 type httpServer struct {
@@ -87,7 +91,80 @@ func (s *httpServer) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// split URL into: scheme, hostname, port
+func (s *httpServer) splitURL(rawURL string) (string, string, string, error) {
+	// lowercase the URL to avoid case sensitivity issues
+	parsedURL, err := url.Parse(strings.ToLower((rawURL)))
+	if err != nil {
+		return "", "", "", fmt.Errorf("error parsing URL: %w", err)
+	}
+
+	host, port, err := net.SplitHostPort(parsedURL.Host)
+	if err != nil {
+		// if there's no port, the host itself is returned
+		if parsedURL.Host != "" {
+			return parsedURL.Scheme, parsedURL.Host, "", nil
+		}
+		return "", "", "", fmt.Errorf("error splitting host and port: %w", err)
+	}
+
+	return parsedURL.Scheme, host, port, nil
+}
+
+func (s *httpServer) isAllowedOrigin(origin string) bool {
+	originScheme, originHost, originPort, err := s.splitURL(origin)
+	if err != nil {
+		s.lggr.Debug("error parsing origin URL", err)
+		return false
+	}
+	for _, allowed := range s.config.CORSAllowedOrigins {
+		// probably better to do this once when server starts and store it in a map
+		// this is an easier solution so we don't have to apply more changes to the code
+		// just need to be careful when specifying allowed origins in the config file
+		allowedScheme, allowedHost, allowedPort, err := s.splitURL(allowed)
+		if err != nil {
+			s.lggr.Debug("error parsing allowed origin URL", err)
+			continue
+		}
+		// skip if the scheme doesn't match at all
+		if originScheme != allowedScheme {
+			continue
+		}
+		// skip if the port doesn't match at all
+		if originPort != allowedPort {
+			continue
+		}
+		// check for exact host match (e.g., remix.com)
+		if originHost == allowedHost {
+			return true
+		}
+		// check for wildcard host match (e.g., *.remix.com)
+		if strings.HasPrefix(allowedHost, "*.") {
+			allowedHost = allowedHost[2:]
+			if strings.HasSuffix(originHost, allowedHost) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 func (s *httpServer) handleRequest(w http.ResponseWriter, r *http.Request) {
+	if s.config.CORSEnabled {
+		origin := r.Header.Get("Origin")
+		if s.isAllowedOrigin(origin) {
+			w.Header().Set("Access-Control-Allow-Origin", origin)
+			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+		}
+
+		// handle preflight requests
+		if r.Method == http.MethodOptions {
+			w.WriteHeader(http.StatusNoContent)
+			return
+		}
+	}
+
 	source := http.MaxBytesReader(nil, r.Body, s.config.MaxRequestBytes)
 	rawMessage, err := io.ReadAll(source)
 	if err != nil {
diff --git a/core/services/gateway/network/httpserver_test.go b/core/services/gateway/network/httpserver_test.go
index 2d85f3d583e..1a433df6a53 100644
--- a/core/services/gateway/network/httpserver_test.go
+++ b/core/services/gateway/network/httpserver_test.go
@@ -22,7 +22,7 @@ const (
 	HTTPTestPath = "/test_path"
 )
 
-func startNewServer(t *testing.T, maxRequestBytes int64, readTimeoutMillis uint32) (server network.HttpServer, handler *mocks.HTTPRequestHandler, url string) {
+func startNewServer(t *testing.T, maxRequestBytes int64, readTimeoutMillis uint32, enabledCORS bool, allowedOrigins []string) (server network.HttpServer, handler *mocks.HTTPRequestHandler, url string) {
 	config := &network.HTTPServerConfig{
 		Host:                 HTTPTestHost,
 		Port:                 0,
@@ -33,6 +33,8 @@ func startNewServer(t *testing.T, maxRequestBytes int64, readTimeoutMillis uint3
 		WriteTimeoutMillis:   10_000,
 		RequestTimeoutMillis: 10_000,
 		MaxRequestBytes:      maxRequestBytes,
+		CORSEnabled:          enabledCORS,
+		CORSAllowedOrigins:   allowedOrigins,
 	}
 
 	handler = mocks.NewHTTPRequestHandler(t)
@@ -46,8 +48,11 @@ func startNewServer(t *testing.T, maxRequestBytes int64, readTimeoutMillis uint3
 	return
 }
 
-func sendRequest(t *testing.T, url string, body []byte) *http.Response {
-	req, err := http.NewRequestWithContext(testutils.Context(t), "POST", url, bytes.NewBuffer(body))
+func sendRequest(t *testing.T, url string, body []byte, httpMethod string, origin *string) *http.Response {
+	req, err := http.NewRequestWithContext(testutils.Context(t), httpMethod, url, bytes.NewBuffer(body))
+	if origin != nil {
+		req.Header.Set("Origin", *origin)
+	}
 	require.NoError(t, err)
 	client := &http.Client{}
 	resp, err := client.Do(req)
@@ -57,12 +62,12 @@ func sendRequest(t *testing.T, url string, body []byte) *http.Response {
 
 func TestHTTPServer_HandleRequest_Correct(t *testing.T) {
 	t.Parallel()
-	server, handler, url := startNewServer(t, 100_000, 100_000)
+	server, handler, url := startNewServer(t, 100_000, 100_000, false, nil)
 	defer server.Close()
 
 	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
 
-	resp := sendRequest(t, url, []byte("0123456789"))
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, nil)
 	respBytes, err := io.ReadAll(resp.Body)
 	require.NoError(t, err)
 	require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -71,22 +76,163 @@ func TestHTTPServer_HandleRequest_Correct(t *testing.T) {
 
 func TestHTTPServer_HandleRequest_RequestBodyTooBig(t *testing.T) {
 	t.Parallel()
-	server, _, url := startNewServer(t, 5, 100_000)
+	server, _, url := startNewServer(t, 5, 100_000, false, nil)
 	defer server.Close()
 
-	resp := sendRequest(t, url, []byte("0123456789"))
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, nil)
 	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
 }
 
 func TestHTTPServer_HandleHealthCheck(t *testing.T) {
 	t.Parallel()
-	server, _, url := startNewServer(t, 100_000, 100_000)
+	server, _, url := startNewServer(t, 100_000, 100_000, false, nil)
 	defer server.Close()
 
 	url = strings.Replace(url, HTTPTestPath, network.HealthCheckPath, 1)
-	resp := sendRequest(t, url, []byte{})
+	resp := sendRequest(t, url, []byte{}, http.MethodPost, nil)
 	respBytes, err := io.ReadAll(resp.Body)
 	require.NoError(t, err)
 	require.Equal(t, http.StatusOK, resp.StatusCode)
 	require.Equal(t, []byte(network.HealthCheckResponse), respBytes)
 }
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromAllowedOrigin(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://remix.ethereum.org", "https://another.valid.origin.com"})
+	defer server.Close()
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin := "https://remix.ethereum.org"
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, origin, resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "GET, POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "Content-Type", resp.Header.Get("Access-Control-Allow-Headers"))
+}
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromAllowedOriginWildcards(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://*.ethereum.org", "https://*.valid.domain.com", "http://*.gov"})
+	defer server.Close()
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin := "https://remix.ethereum.org"
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, origin, resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "GET, POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "Content-Type", resp.Header.Get("Access-Control-Allow-Headers"))
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin = "https://another.valid.domain.com"
+	resp = sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err = io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, origin, resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "GET, POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "Content-Type", resp.Header.Get("Access-Control-Allow-Headers"))
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin = "http://example.gov"
+	resp = sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err = io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, origin, resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "GET, POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "Content-Type", resp.Header.Get("Access-Control-Allow-Headers"))
+}
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromAllowedOrigin_PreflightRequest(t *testing.T) {
+	t.Parallel()
+	server, _, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://remix.ethereum.org", "https://another.valid.origin.com"})
+	defer server.Close()
+
+	origin := "https://remix.ethereum.org"
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodOptions, &origin)
+	respBytes, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusNoContent, resp.StatusCode)
+	require.Empty(t, respBytes)
+	require.Equal(t, origin, resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "GET, POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "Content-Type", resp.Header.Get("Access-Control-Allow-Headers"))
+}
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromNotAllowedOrigin(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://remix.ethereum.org", "https://another.valid.origin.com"})
+	defer server.Close()
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin := "https://not.allowed.origin.com"
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Headers"))
+}
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromNotAllowedOriginWildcards(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://*.ethereum.org", "https://*.valid.domain.com", "http://example.gov:8080"})
+	defer server.Close()
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin := "https://ethereum.remix.org" // doesn't end with ethereum.org
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Headers"))
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin = "http://another.valid.domain.org" // http instead of https
+	resp = sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err = io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Headers"))
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin = "http://example.gov" // port missing
+	resp = sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err = io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "", resp.Header.Get("Access-Control-Allow-Headers"))
+}
diff --git a/core/testdata/testspecs/v2_specs.go b/core/testdata/testspecs/v2_specs.go
index f2c190f5a70..48a047cae85 100644
--- a/core/testdata/testspecs/v2_specs.go
+++ b/core/testdata/testspecs/v2_specs.go
@@ -238,6 +238,8 @@ Port = %d
 ReadTimeoutMillis = 1_000
 RequestTimeoutMillis = 10_000
 WriteTimeoutMillis = 1_000
+CORSEnabled = false
+CORSAllowedOrigins = []
 [gatewayConfig.HTTPClientConfig]
 MaxResponseBytes = 100_000_000
 `
diff --git a/system-tests/lib/cre/don/jobs/definitions.go b/system-tests/lib/cre/don/jobs/definitions.go
index a6c5b3ef3a9..94e5067d9f5 100644
--- a/system-tests/lib/cre/don/jobs/definitions.go
+++ b/system-tests/lib/cre/don/jobs/definitions.go
@@ -105,6 +105,8 @@ func AnyGateway(bootstrapNodeID string, chainID uint64, donID uint32, extraAllow
 	ReadTimeoutMillis = 1_000
 	RequestTimeoutMillis = 10_000
 	WriteTimeoutMillis = 1_000
+	CORSEnabled = false
+	CORSAllowedOrigins = []
 	[gatewayConfig.HTTPClientConfig]
 	MaxResponseBytes = 100_000_000
 `,


Reviews:
user: login: bolekk
body: LGTM. It would be cool to test locally with a Gateway node, some locally configured domain (maybe even "localhost" would work?) and a real browser.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T21:15:34Z
user: login: ibrajer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:49:38Z
user: login: ibrajer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:51:00Z
user: login: bolekk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T23:51:11Z
user: login: ibrajer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T07:17:44Z
user: login: anirudhwarrier
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T07:45:52Z
user: login: ilija42
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:45:38Z

Review comments:
diff_hunk: @@ -87,7 +89,31 @@ func (s *httpServer) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+func (s *httpServer) isAllowedOrigin(origin string) bool {
+	for _, allowed := range s.config.CORSAllowedOrigins {
+		if origin == allowed {
path: core/services/gateway/network/httpserver.go
user: login: bolekk
body: Is this correct? Should we be case-sensitive? It feels like we could use some ready library to compare URLs here.

Apart from that it looks like we don't support wildcards. I believe this is on purpose so maybe leave a comment?
created_at: 2025-04-07T21:13:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,6 +105,8 @@ func AnyGateway(bootstrapNodeID string, chainID uint64, donID uint32, extraAllow
 	ReadTimeoutMillis = 1_000
 	RequestTimeoutMillis = 10_000
 	WriteTimeoutMillis = 1_000
+	CORSEnabled = false
+  CORSAllowedOrigins = []
path: system-tests/lib/cre/don/jobs/definitions.go
user: login: bolekk
body: tabs vs spaces?
created_at: 2025-04-07T21:14:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,6 +105,8 @@ func AnyGateway(bootstrapNodeID string, chainID uint64, donID uint32, extraAllow
 	ReadTimeoutMillis = 1_000
 	RequestTimeoutMillis = 10_000
 	WriteTimeoutMillis = 1_000
+	CORSEnabled = false
+  CORSAllowedOrigins = []
path: system-tests/lib/cre/don/jobs/definitions.go
user: login: ibrajer
body: Fixed!
created_at: 2025-04-09T17:49:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,7 +89,31 @@ func (s *httpServer) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+func (s *httpServer) isAllowedOrigin(origin string) bool {
+	for _, allowed := range s.config.CORSAllowedOrigins {
+		if origin == allowed {
path: core/services/gateway/network/httpserver.go
user: login: ibrajer
body: Removed case sensitivity, added support for wildcards, more tests... The only thing we won't support is having "*" as an allowed origin, but I think this is acceptable.
created_at: 2025-04-09T17:51:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,22 +76,163 @@ func TestHTTPServer_HandleRequest_Correct(t *testing.T) {
 
 func TestHTTPServer_HandleRequest_RequestBodyTooBig(t *testing.T) {
 	t.Parallel()
-	server, _, url := startNewServer(t, 5, 100_000)
+	server, _, url := startNewServer(t, 5, 100_000, false, nil)
 	defer server.Close()
 
-	resp := sendRequest(t, url, []byte("0123456789"))
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, nil)
 	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
 }
 
 func TestHTTPServer_HandleHealthCheck(t *testing.T) {
 	t.Parallel()
-	server, _, url := startNewServer(t, 100_000, 100_000)
+	server, _, url := startNewServer(t, 100_000, 100_000, false, nil)
 	defer server.Close()
 
 	url = strings.Replace(url, HTTPTestPath, network.HealthCheckPath, 1)
-	resp := sendRequest(t, url, []byte{})
+	resp := sendRequest(t, url, []byte{}, http.MethodPost, nil)
 	respBytes, err := io.ReadAll(resp.Body)
 	require.NoError(t, err)
 	require.Equal(t, http.StatusOK, resp.StatusCode)
 	require.Equal(t, []byte(network.HealthCheckResponse), respBytes)
 }
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromAllowedOrigin(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://remix.ethereum.org", "https://another.valid.origin.com"})
+	defer server.Close()
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin := "https://remix.ethereum.org"
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, origin, resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "GET, POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "Content-Type", resp.Header.Get("Access-Control-Allow-Headers"))
+}
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromAllowedOriginWildcards(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://remix.*.org", "https://another.valid.*.com", "http://*.example.com"})
path: core/services/gateway/network/httpserver_test.go
user: login: bolekk
body: This is going too far IMO, nobody will use wildcards in the middle. We should reduce it to prefix only, like "http://*.example.com". That will also make the code simpler. 
created_at: 2025-04-09T23:51:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,22 +76,163 @@ func TestHTTPServer_HandleRequest_Correct(t *testing.T) {
 
 func TestHTTPServer_HandleRequest_RequestBodyTooBig(t *testing.T) {
 	t.Parallel()
-	server, _, url := startNewServer(t, 5, 100_000)
+	server, _, url := startNewServer(t, 5, 100_000, false, nil)
 	defer server.Close()
 
-	resp := sendRequest(t, url, []byte("0123456789"))
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, nil)
 	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
 }
 
 func TestHTTPServer_HandleHealthCheck(t *testing.T) {
 	t.Parallel()
-	server, _, url := startNewServer(t, 100_000, 100_000)
+	server, _, url := startNewServer(t, 100_000, 100_000, false, nil)
 	defer server.Close()
 
 	url = strings.Replace(url, HTTPTestPath, network.HealthCheckPath, 1)
-	resp := sendRequest(t, url, []byte{})
+	resp := sendRequest(t, url, []byte{}, http.MethodPost, nil)
 	respBytes, err := io.ReadAll(resp.Body)
 	require.NoError(t, err)
 	require.Equal(t, http.StatusOK, resp.StatusCode)
 	require.Equal(t, []byte(network.HealthCheckResponse), respBytes)
 }
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromAllowedOrigin(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://remix.ethereum.org", "https://another.valid.origin.com"})
+	defer server.Close()
+
+	handler.On("ProcessRequest", mock.Anything, mock.Anything).Return([]byte("response"), 200)
+
+	origin := "https://remix.ethereum.org"
+	resp := sendRequest(t, url, []byte("0123456789"), http.MethodPost, &origin)
+	respBytes, err := io.ReadAll(resp.Body)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusOK, resp.StatusCode)
+	require.Equal(t, []byte("response"), respBytes)
+	require.Equal(t, origin, resp.Header.Get("Access-Control-Allow-Origin"))
+	require.Equal(t, "GET, POST, OPTIONS", resp.Header.Get("Access-Control-Allow-Methods"))
+	require.Equal(t, "Content-Type", resp.Header.Get("Access-Control-Allow-Headers"))
+}
+
+func TestHTTPServer_HandleRequest_CORSEnabled_FromAllowedOriginWildcards(t *testing.T) {
+	t.Parallel()
+	server, handler, url := startNewServer(t, 100_000, 100_000, true,
+		[]string{"https://remix.*.org", "https://another.valid.*.com", "http://*.example.com"})
path: core/services/gateway/network/httpserver_test.go
user: login: ibrajer
body: Done!
created_at: 2025-04-10T07:17:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-04T12:43:17Z
body: I see you updated files related to `core`. Please run `pnpm changeset` in the root directory to add a changeset as well as in the text include at least one of the following tags:
- `#added` For any new functionality added.
- `#breaking_change` For any functionality that requires manual action for the node to boot.
- `#bugfix` For bug fixes.
- `#changed` For any change to the existing functionality.
- `#db_update` For any feature that introduces updates to database schema.
- `#deprecation_notice` For any upcoming deprecation functionality.
- `#internal` For changesets that need to be excluded from the final changelog.
- `#nops` For any feature that is NOP facing and needs to be in the official Release Notes for the release.
- `#removed` For any functionality/config that is removed.
- `#updated` For any functionality that is updated.
- `#wip` For any change that is not ready yet and external communication about it should be held off till it is feature complete.

> :medal_military: No JIRA issue number found in: PR title, commit message, or branch name. Please include the issue ID in one of these.
<!-- thollander/actions-comment-pull-request "changeset-core" -->
author_association: CONTRIBUTOR
user: login: cl-sonarqube-production[bot]
created_at: 2025-04-10T06:50:43Z
body: ## [![Quality Gate passed](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarqube.main.prod.cldev.sh/dashboard?id=smartcontractkit_chainlink&pullRequest=17114) **Quality Gate passed**  
Issues  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=17114&resolved=false) [0 New issues](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=17114&resolved=false)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/fixed-16px.png '')](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&fixedInPullRequest=17114) [0 Fixed issues](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&fixedInPullRequest=17114)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/accepted-16px.png '')](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=17114&issueStatuses=ACCEPTED) [0 Accepted issues](https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=17114&issueStatuses=ACCEPTED)

Measures  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/security_hotspots?id=smartcontractkit_chainlink&pullRequest=17114) [0 Security Hotspots](https://sonarqube.main.prod.cldev.sh/security_hotspots?id=smartcontractkit_chainlink&pullRequest=17114)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=17114&metric=new_coverage&view=list) [0.0% Coverage on New Code](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=17114&metric=new_coverage&view=list)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=17114&metric=new_duplicated_lines_density&view=list) [0.0% Duplication on New Code](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=17114&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube](https://sonarqube.main.prod.cldev.sh/dashboard?id=smartcontractkit_chainlink&pullRequest=17114)


author_association: NONE