Skip to content

Commit 3d77c0a

Browse files
authored
feat(backend)!: Add CORS policy for GraphQL subscriptions (#1272)
- Add CORS policy for GraphQL subscriptions - Add tests for CORS policy configuration - Add environment variable to configure allowed origins for CORS policy BREAKING CHANGE: the backend now reads a new environment variable: `CHECK_ORIGIN_ALLOWED_ORIGINS` that sets the allowed origins for graphql subscriptions. If not set the subscriptions will not work in a Kubernetes environment. Please update your environment accordingly Signed-off-by: Osman Hadzic <osman.hadzic@secomind.com>
1 parent e9323c5 commit 3d77c0a

6 files changed

Lines changed: 158 additions & 4 deletions

File tree

backend/config/dev.exs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ url_host = System.get_env("URL_HOST", "localhost")
2222
url_port = System.get_env("URL_PORT", "4000")
2323
url_scheme = System.get_env("URL_SCHEME", "http")
2424

25+
check_origin =
26+
case System.get_env("CHECK_ORIGIN_ALLOWED_ORIGINS") do
27+
nil ->
28+
["//localhost", "//127.0.0.1"]
29+
30+
raw_origins ->
31+
raw_origins
32+
|> String.split(",", trim: true)
33+
|> Enum.map(&String.trim/1)
34+
|> Enum.reject(&(&1 == ""))
35+
end
36+
2537
database = %{
2638
username: System.get_env("DATABASE_USERNAME", "postgres"),
2739
password: System.get_env("DATABASE_PASSWORD", "postgres"),
@@ -64,7 +76,7 @@ config :edgehog, EdgehogWeb.Endpoint,
6476
scheme: url_scheme,
6577
port: url_port
6678
],
67-
check_origin: false,
79+
check_origin: check_origin,
6880
code_reloader: true,
6981
debug_errors: true,
7082
secret_key_base: "uEb3NXr0KodsrjUUUo98VBEExFFAolxlfOW7ZzP/OGgd1R2pDhwMddZXjvUp/3MW",

backend/config/runtime.exs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,23 @@ if config_env() == :prod do
234234
url_port = System.get_env("URL_PORT", "443")
235235
url_scheme = System.get_env("URL_SCHEME", "https")
236236

237+
check_origin_default = ["#{url_scheme}://#{url_host}:#{url_port}"]
238+
239+
check_origin =
240+
case System.get_env("CHECK_ORIGIN_ALLOWED_ORIGINS") do
241+
nil ->
242+
check_origin_default
243+
244+
raw_origins ->
245+
parsed_origins =
246+
raw_origins
247+
|> String.split(",", trim: true)
248+
|> Enum.map(&String.trim/1)
249+
|> Enum.reject(&(&1 == ""))
250+
251+
if parsed_origins == [], do: check_origin_default, else: parsed_origins
252+
end
253+
237254
forwarder_secure_sessions? =
238255
System.get_env("EDGEHOG_FORWARDER_SECURE_SESSIONS", "true") == "true"
239256

@@ -263,6 +280,7 @@ if config_env() == :prod do
263280
scheme: url_scheme,
264281
port: url_port
265282
],
283+
check_origin: check_origin,
266284
secret_key_base: secret_key_base
267285

268286
if forwarder_hostname != nil &&

backend/docs/pages/admin/deploying_with_kubernetes.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ spec:
303303
value: "4000"
304304
- name: URL_HOST
305305
value: <BACKEND-HOST>
306+
- name: CHECK_ORIGIN_ALLOWED_ORIGINS
307+
value: <CHECK-ORIGIN-ALLOWED-ORIGINS>
306308
- name: DATABASE_HOSTNAME
307309
value: <DATABASE-HOSTNAME>
308310
- name: DATABASE_NAME
@@ -439,6 +441,7 @@ spec:
439441
Values to be replaced
440442
441443
- `BACKEND-HOST`: the host of the Edgehog backend (see the [Creating DNS entries](#creating-dns-entries) section).
444+
- `CHECK-ORIGIN-ALLOWED-ORIGINS`: a comma-separated list of allowed origins for websocket origin checks (for example `https://edgehog.example.com,https://ops.edgehog.example.com`). If omitted, it falls back to the backend URL.
442445
- `DATABASE-HOSTNAME`: the hostname of the PostgreSQL database.
443446
- `MAX-UPLOAD-SIZE-BYTES`: the maximum dimension for uploads, particularly relevant for OTA updates.
444447
If omitted, it defaults to 4 Gigabytes.

backend/lib/edgehog_web/endpoint.ex

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# This file is part of Edgehog.
33
#
4-
# Copyright 2021-2023 SECO Mind Srl
4+
# Copyright 2021-2026 SECO Mind Srl
55
#
66
# Licensed under the Apache License, Version 2.0 (the "License");
77
# you may not use this file except in compliance with the License.
@@ -32,8 +32,7 @@ defmodule EdgehogWeb.Endpoint do
3232
]
3333

3434
socket "/socket", EdgehogWeb.GqlSocket,
35-
# TODO: Enable `check_origin` (and configure allowed origins) to mitigate Cross-Site WebSocket Hijacking.
36-
websocket: [connect_info: [:peer_data, :x_headers], check_origin: false],
35+
websocket: [connect_info: [:peer_data, :x_headers]],
3736
longpoll: [connect_info: [:peer_data, :x_headers]]
3837

3938
plug PlugHeartbeat, path: "/health"
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#
2+
# This file is part of Edgehog.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# SPDX-License-Identifier: Apache-2.0
19+
#
20+
21+
defmodule EdgehogWeb.Schema.Subscriptions.CheckOriginConfigTest do
22+
use ExUnit.Case, async: false
23+
24+
defp read_endpoint_config!(config_file, env) do
25+
config_file
26+
|> Config.Reader.read!(env: env, target: :host)
27+
|> Keyword.get(:edgehog, [])
28+
|> Keyword.fetch!(EdgehogWeb.Endpoint)
29+
end
30+
31+
defp with_env(overrides, fun) do
32+
previous = for {key, _value} <- overrides, into: %{}, do: {key, System.get_env(key)}
33+
34+
Enum.each(overrides, fn
35+
{key, nil} -> System.delete_env(key)
36+
{key, value} -> System.put_env(key, value)
37+
end)
38+
39+
try do
40+
fun.()
41+
after
42+
Enum.each(previous, fn
43+
{key, nil} -> System.delete_env(key)
44+
{key, value} -> System.put_env(key, value)
45+
end)
46+
end
47+
end
48+
49+
describe "config/dev.exs check_origin" do
50+
test "defaults to localhost origins when env var is not set" do
51+
with_env([{"CHECK_ORIGIN_ALLOWED_ORIGINS", nil}], fn ->
52+
endpoint = read_endpoint_config!("config/dev.exs", :dev)
53+
54+
assert Keyword.fetch!(endpoint, :check_origin) == ["//localhost", "//127.0.0.1"]
55+
end)
56+
end
57+
58+
test "uses comma-separated CHECK_ORIGIN_ALLOWED_ORIGINS" do
59+
with_env(
60+
[
61+
{"CHECK_ORIGIN_ALLOWED_ORIGINS", "http://localhost:5173, http://edgehog.localhost"}
62+
],
63+
fn ->
64+
endpoint = read_endpoint_config!("config/dev.exs", :dev)
65+
66+
assert Keyword.fetch!(endpoint, :check_origin) == [
67+
"http://localhost:5173",
68+
"http://edgehog.localhost"
69+
]
70+
end
71+
)
72+
end
73+
end
74+
75+
describe "config/runtime.exs check_origin" do
76+
test "defaults to URL_SCHEME://URL_HOST:URL_PORT in prod" do
77+
with_env(
78+
[
79+
{"DATABASE_USERNAME", "postgres"},
80+
{"DATABASE_PASSWORD", "postgres"},
81+
{"DATABASE_HOSTNAME", "localhost"},
82+
{"DATABASE_NAME", "edgehog"},
83+
{"SECRET_KEY_BASE", "test_secret_key_base"},
84+
{"URL_HOST", "api.edgehog.localhost"},
85+
{"URL_SCHEME", "https"},
86+
{"URL_PORT", "443"},
87+
{"CHECK_ORIGIN_ALLOWED_ORIGINS", nil}
88+
],
89+
fn ->
90+
endpoint = read_endpoint_config!("config/runtime.exs", :prod)
91+
92+
assert Keyword.fetch!(endpoint, :check_origin) == ["https://api.edgehog.localhost:443"]
93+
end
94+
)
95+
end
96+
97+
test "uses CHECK_ORIGIN_ALLOWED_ORIGINS in prod when provided" do
98+
with_env(
99+
[
100+
{"DATABASE_USERNAME", "postgres"},
101+
{"DATABASE_PASSWORD", "postgres"},
102+
{"DATABASE_HOSTNAME", "localhost"},
103+
{"DATABASE_NAME", "edgehog"},
104+
{"SECRET_KEY_BASE", "test_secret_key_base"},
105+
{"URL_HOST", "api.edgehog.localhost"},
106+
{"URL_SCHEME", "https"},
107+
{"URL_PORT", "443"},
108+
{"CHECK_ORIGIN_ALLOWED_ORIGINS", "https://ui.edgehog.localhost, https://ops.edgehog.localhost"}
109+
],
110+
fn ->
111+
endpoint = read_endpoint_config!("config/runtime.exs", :prod)
112+
113+
assert Keyword.fetch!(endpoint, :check_origin) == [
114+
"https://ui.edgehog.localhost",
115+
"https://ops.edgehog.localhost"
116+
]
117+
end
118+
)
119+
end
120+
end
121+
end

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ services:
6464
URL_HOST: edgehog-backend
6565
URL_PORT: 4000
6666
URL_SCHEME: http
67+
CHECK_ORIGIN_ALLOWED_ORIGINS: http://${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}
6768
EDGEHOG_FORWARDER_HOSTNAME: device-forwarder.${DOCKER_COMPOSE_EDGEHOG_BASE_DOMAIN}
6869
EDGEHOG_FORWARDER_PORT: 80
6970
EDGEHOG_FORWARDER_SECURE_SESSIONS: "false"

0 commit comments

Comments
 (0)