diff --git a/CHANGELOG.md b/CHANGELOG.md index 5457e144..01e29013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,12 @@ * Web: a new node's long name in the chat is underlined only on hover or keyboard focus (SPEC LA6) * Web: long names and hardware models no longer widen the page past the nodes table's column: while the table would overflow, those cells break inside a word (SPEC PO1) * Web: static pages fit phone screens with long inline code and wide tables: inline code breaks anywhere, and a table wider than the page scrolls inside its own box (SPEC PO2/PO3) +* Web: HTML pages send a `Content-Security-Policy` and every response `Referrer-Policy: strict-origin-when-cross-origin`; basemap tiles and images on custom pages must use `https://` (SPEC HD1-HD3) +* Web: the canonical link, `og:image`, `robots.txt`, `sitemap.xml` and the `/og-image.png` capture keep the scheme `INSTANCE_DOMAIN` names, and otherwise take only `http` or `https` from forwarded headers (SPEC HD4) +* Web: the Chromium that renders `/og-image.png` starts without `API_TOKEN` or the app's other environment variables (SPEC HD5) +* Web: the header and footer print `SITE_NAME` and the version as text, and asset URLs, module preloads and the import map carry the version URL-encoded (SPEC HD6) +* Web: the unused `/potatomesh-logo.svg` route is removed; the static handler serves the logo as before (SPEC HD7) +* Docs: `deploy/nginx.example.conf` and `DOCKER.md` clear the `Forwarded` request header, so the scheme of generated URLs comes from `X-Forwarded-Proto` (SPEC HD4) * Data: the MeshCore ingestor posts each roster contact's node once per connect instead of three times, and its position once instead of twice; a re-advert of a known contact posts its node and position once each instead of twice, or twice instead of three times on firmware 1.16 and later (SPEC CU1-CU3) ### Features diff --git a/DOCKER.md b/DOCKER.md index 4b74107c..9d567b04 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -47,7 +47,7 @@ Additional environment variables are optional: | Variable | Default | Purpose | | --- | --- | --- | | `API_TOKEN` | _required_ | Shared secret used by the ingestor and API clients for authenticated `POST` requests. | -| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) advertised by the web UI, metadata, and API responses. | +| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) advertised by the web UI, metadata, and API responses. With a scheme (`https://mesh.example.org`), generated URLs keep that scheme whatever the proxy forwards. | | `SITE_NAME` | `"PotatoMesh Demo"` | Title and branding surfaced in the web UI. | | `CHANNEL` | `"#LongFast"` | Default LoRa channel label displayed on the dashboard. | | `FREQUENCY` | `"915MHz"` | Default LoRa frequency description shown in the UI. | @@ -125,17 +125,23 @@ terminate TLS in a reverse proxy in front of it. A ready-to-adapt nginx example lives at [`deploy/nginx.example.conf`](deploy/nginx.example.conf); the notes below explain the parts that matter. -Forwarded headers (required). The app derives its public scheme and host - -used for `INSTANCE_DOMAIN`, page metadata, the sitemap, and federation links - -from `X-Forwarded-Proto` and the `Host` header. Forward both, or generated URLs -resolve to the wrong scheme/host: +Forwarded headers (required). Generated URLs (page metadata, the sitemap) take +their scheme from `INSTANCE_DOMAIN` when it names one (`https://mesh.example.org`), +else from `Forwarded` or `X-Forwarded-Proto`, `http` or `https` only; without an +`INSTANCE_DOMAIN` their host comes from `Host`. Set `Host` and +`X-Forwarded-Proto`, and clear `Forwarded`: ```nginx proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header Forwarded ""; ``` +Security headers. The app sends `Content-Security-Policy` on HTML pages and +`Referrer-Policy: strict-origin-when-cross-origin` on every response. Do not set +either header in the proxy. + Static-asset caching. Every JS module and `base.css` is versioned (`?v=`) and safe to cache immutably for a year. Images, icons, and fonts are not versioned - keep a short TTL with revalidation. Two diff --git a/README.md b/README.md index b76e90c9..5851691a 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ The web app can be configured with environment variables (defaults shown): | Variable | Default | Purpose | | --- | --- | --- | | `API_TOKEN` | _required_ | Shared secret that authorizes ingestors and API clients making `POST` requests. | -| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) used for metadata, federation, and generated API links. | +| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) used for metadata, federation, and generated API links. With a scheme (`https://mesh.example.org`), generated URLs keep that scheme whatever a proxy forwards. | | `SITE_NAME` | `"PotatoMesh Demo"` | Title and header displayed in the UI. | | `MESHTASTIC_PRESET` | `"#LongFast"` | Meshtastic radio preset shown in the join strip, meta description, and federation directory (e.g. `MediumFast`). | | `MESHTASTIC_FREQ` | `"915MHz"` | Meshtastic frequency shown alongside the preset. | @@ -137,7 +137,7 @@ Outbound requests. The map loads basemap tiles from two third-party CDNs on every viewport: OpenStreetMap HOT (`tile.openstreetmap.fr`) and CARTO (`basemaps.cartocdn.com`). Only `z/x/y` tile coordinates are sent - no key, cookie, or analytics parameter. Tiles are the only third-party request the -dashboard makes. +dashboard makes. Tile URLs must use `https://`; `http://` tiles do not load. ### Privacy @@ -164,6 +164,7 @@ slug sets the URL and nav label: - Ships with a default `1-about.md`. - Docker: the directory is the `potatomesh_pages` volume (`/app/pages`) - edit pages without rebuilding. - Override the directory with `PAGES_DIR`. +- Images in a page must use `https://` URLs; `http://` images do not load. ### Federation diff --git a/deploy/nginx.example.conf b/deploy/nginx.example.conf index 9d5e597e..989fe823 100644 --- a/deploy/nginx.example.conf +++ b/deploy/nginx.example.conf @@ -108,6 +108,7 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Forwarded ""; # drop a client's own Forwarded: it outranks X-Forwarded-Proto # Live-update SSE (GET /api/events) holds a long-lived streaming response. proxy_read_timeout 3600; diff --git a/web/lib/potato_mesh/application.rb b/web/lib/potato_mesh/application.rb index 25a8a456..fb2e7b58 100644 --- a/web/lib/potato_mesh/application.rb +++ b/web/lib/potato_mesh/application.rb @@ -60,6 +60,8 @@ require_relative "application/data_processing" require_relative "application/filesystem" require_relative "application/api_cache" require_relative "application/asset_cache_control" +require_relative "application/security_headers" +require_relative "application/public_scheme" require_relative "application/pubsub" require_relative "application/pages" require_relative "application/instances" @@ -118,6 +120,9 @@ module PotatoMesh INSTANCE_PUBLIC_KEY_PEM = INSTANCE_PRIVATE_KEY.public_key.export SELF_INSTANCE_ID = Digest::SHA256.hexdigest(INSTANCE_PUBLIC_KEY_PEM) INSTANCE_DOMAIN, INSTANCE_DOMAIN_SOURCE = determine_instance_domain + # The scheme INSTANCE_DOMAIN names when it is a URL ("http" or "https"), + # which pins the scheme of the public URLs (SPEC HD4); nil for a bare host. + INSTANCE_DOMAIN_SCHEME = App::PublicScheme.from_instance_domain(ENV["INSTANCE_DOMAIN"]) # Adjust the runtime logger severity to match the DEBUG flag. # @@ -225,6 +230,11 @@ module PotatoMesh set :logger, app_logger use Rack::CommonLogger, app_logger use Rack::Deflater + # Content-Security-Policy on HTML pages, Referrer-Policy on every response + # (SPEC HD1, HD2). The import map is the pages' one inline script, hashed + # here once from the memoised JSON the layout prints (SPEC HD3, AV3). + use PotatoMesh::App::SecurityHeaders, + content_security_policy: PotatoMesh::App::SecurityHeaders.content_security_policy([asset_import_map_json]) # Long-cache for version-busted static JS/CSS (returning-visitor caching); # the nginx disk-serve path sets the same header first when enabled, so this # never overwrites an existing one. The immutable (year-long) pin is used diff --git a/web/lib/potato_mesh/application/asset_cache_control.rb b/web/lib/potato_mesh/application/asset_cache_control.rb index 0877cee1..5888d7af 100644 --- a/web/lib/potato_mesh/application/asset_cache_control.rb +++ b/web/lib/potato_mesh/application/asset_cache_control.rb @@ -61,7 +61,7 @@ module PotatoMesh # Unversioned site icons served from the app root (favicon + logo). They # bypass the +/assets/**+ pipeline entirely — Sinatra's static-file handler - # serves them straight off +public/+ (and the fallback routes use + # serves them straight off +public/+ (and the favicon fallback route uses # +send_file+), neither of which sets a +Cache-Control+, so the browser # revalidates them on every page load. They carry no +?v=+ buster, so a # bounded lifetime (not +immutable+) is used: a changed icon self-heals diff --git a/web/lib/potato_mesh/application/helpers/asset_helpers.rb b/web/lib/potato_mesh/application/helpers/asset_helpers.rb index 48783777..6cd89a07 100644 --- a/web/lib/potato_mesh/application/helpers/asset_helpers.rb +++ b/web/lib/potato_mesh/application/helpers/asset_helpers.rb @@ -14,6 +14,9 @@ # frozen_string_literal: true +require "erb" +require "json" + module PotatoMesh module App # Builds the JSON import map that version-stamps every served JS module. @@ -28,33 +31,57 @@ module PotatoMesh # # A module absent from the map degrades to today's unversioned-but-working # load — a missing entry can never break a working import. + # + # Every +?v=+ value is {.version_query}, the URL-encoded version, in the + # map's targets, the preload hrefs and +asset_url+ alike, so a preload and + # its import share one URL; the JSON is script-safe, so the inline import + # map cannot be left early. The Content-Security-Policy hashes {.json} at + # boot, the string the layout prints (SPEC HD3, HD6). module AssetImportMap module_function + # Escapes that keep JSON inside a +)) + expect(last_response.body).to include(%()) end end diff --git a/web/spec/helpers/asset_helpers_spec.rb b/web/spec/helpers/asset_helpers_spec.rb index 9478a9ff..e760405d 100644 --- a/web/spec/helpers/asset_helpers_spec.rb +++ b/web/spec/helpers/asset_helpers_spec.rb @@ -49,6 +49,12 @@ RSpec.describe PotatoMesh::App::Helpers do it "reads the cache key from the APP_VERSION constant" do expect(helper.asset_url("/assets/js/theme.js")).to end_with("?v=1.2.3") end + + it "URL-encodes a version holding a space, quotes, markup and an ampersand (SPEC HD6)" do + allow(helper).to receive(:app_constant).with(:APP_VERSION).and_return(%(v1.0.0 "<>&')) + + expect(helper.asset_url("/assets/js/theme.js")).to eq("/assets/js/theme.js?v=v1.0.0%20%22%3C%3E%26%27") + end end # --------------------------------------------------------------------------- @@ -64,16 +70,16 @@ RSpec.describe PotatoMesh::App::Helpers do end end - it "adds the /charts page entry only on the charts view" do - expect(helper.asset_preload_entry_modules(:charts)).to eq(base + ["/assets/js/app/charts-page.js"]) + it "adds the /charts boot module only on the charts view" do + expect(helper.asset_preload_entry_modules(:charts)).to eq(base + ["/assets/js/app/charts-page-boot.js"]) end - it "adds the /federation page entry only on the federation view" do - expect(helper.asset_preload_entry_modules(:federation)).to eq(base + ["/assets/js/app/federation-page.js"]) + it "adds the /federation boot module only on the federation view" do + expect(helper.asset_preload_entry_modules(:federation)).to eq(base + ["/assets/js/app/federation-page-boot.js"]) end - it "adds the node-detail page entry only on the node_detail view" do - expect(helper.asset_preload_entry_modules(:node_detail)).to eq(base + ["/assets/js/app/node-page.js"]) + it "adds the node-detail boot module only on the node_detail view" do + expect(helper.asset_preload_entry_modules(:node_detail)).to eq(base + ["/assets/js/app/node-page-boot.js"]) end it "falls back to the shared base for a nil or unrecognised view" do diff --git a/web/spec/helpers/root_helpers_spec.rb b/web/spec/helpers/root_helpers_spec.rb index b8a88ca8..42408eac 100644 --- a/web/spec/helpers/root_helpers_spec.rb +++ b/web/spec/helpers/root_helpers_spec.rb @@ -37,10 +37,18 @@ RSpec.describe PotatoMesh::App::Routes::Root::Helpers do end let(:helper) { harness_class.new } - let(:request_double) { double("request", base_url: "http://upstream.example", scheme: "https") } + + # Point the harness at a real request for +url+ with extra Rack env. + # + # @param url [String] the request URL. + # @param env [Hash] extra Rack env entries (headers as +HTTP_*+ keys). + # @return [void] + def request_to(url, env = {}) + helper.request = Rack::Request.new(Rack::MockRequest.env_for(url).merge(env)) + end before do - helper.request = request_double + request_to("https://upstream.example/") end describe "#public_base_url" do @@ -52,22 +60,38 @@ RSpec.describe PotatoMesh::App::Routes::Root::Helpers do it "honors the request scheme when present" do helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") - allow(request_double).to receive(:scheme).and_return("http") + request_to("http://upstream.example/") expect(helper.public_base_url).to eq("http://potatomesh.net") end it "defaults to https when the scheme is missing" do helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") - allow(request_double).to receive(:scheme).and_return(nil) + request_to("http://upstream.example/", "rack.url_scheme" => nil) expect(helper.public_base_url).to eq("https://potatomesh.net") end - it "falls back to request.base_url when no instance domain is set" do - helper.set_constant(:INSTANCE_DOMAIN, nil) + it "takes http or https from the forwarded headers, never another scheme (SPEC HD4)" do + helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") + request_to("http://upstream.example/", "HTTP_X_FORWARDED_PROTO" => "https", "HTTP_FORWARDED" => "proto=wss") - expect(helper.public_base_url).to eq("http://upstream.example") + expect(helper.public_base_url).to eq("https://potatomesh.net") + end + + it "keeps the scheme INSTANCE_DOMAIN names over the request's (SPEC HD4)" do + helper.set_constant(:INSTANCE_DOMAIN, "potatomesh.net") + helper.set_constant(:INSTANCE_DOMAIN_SCHEME, "https") + request_to("http://upstream.example/", "HTTP_X_FORWARDED_PROTO" => "http") + + expect(helper.public_base_url).to eq("https://potatomesh.net") + end + + it "falls back to the request's host when no instance domain is set" do + helper.set_constant(:INSTANCE_DOMAIN, nil) + request_to("http://upstream.example:8080/", "HTTP_FORWARDED" => "proto=ws") + + expect(helper.public_base_url).to eq("http://upstream.example:8080") end end diff --git a/web/spec/layout_escape_spec.rb b/web/spec/layout_escape_spec.rb new file mode 100644 index 00000000..da302c87 --- /dev/null +++ b/web/spec/layout_escape_spec.rb @@ -0,0 +1,91 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" + +# The header's site name and the footer's version print as text (SPEC HD6): +# SITE_NAME is operator text, and a git tag may hold <, >, " and &. +RSpec.describe "Layout escaping" do + let(:app) { Sinatra::Application } + let(:site_name) { %(Mesh x & "q") } + + before do + allow(PotatoMesh::Config).to receive(:site_name).and_return(site_name) + stub_const("PotatoMesh::Application::APP_VERSION", %(v1.0.0z&)) + end + + it "prints SITE_NAME in the header as text" do + get "/" + + expect(last_response).to be_ok + expect(last_response.body).to include( + %(Mesh <i id="sn">x</i> & "q"), + ) + expect(last_response.body).not_to include('') + end + + it "prints the version in the footer as text" do + get "/charts" + + expect(last_response).to be_ok + expect(last_response.body).to include(%(v1.0.0<b id="ver">z</b>&)) + expect(last_response.body).not_to include(%(v1.0.0)) + end + + it "prints SITE_NAME nowhere raw: title, meta and og tags, JSON-LD and the header" do + get "/" + + expect(last_response.body).not_to include(site_name) + expect(last_response.body).to include("Mesh <i id="sn">x</i> & "q"") + expect(last_response.body).to include(%()) + expect(last_response.body).to include('"name":"Mesh \\u003ci id=\\"sn\\"\\u003ex\\u003c/i\\u003e \\u0026 \\"q\\""') + end + + # A version a git tag may carry: a space, both quotes, markup and an + # ampersand. Every URL that stamps it carries it URL-encoded, the import map + # stays JSON a script element cannot leave, and the map served is the one the + # boot hashes for the Content-Security-Policy (SPEC HD6, HD3). + describe "with a version holding a space, quotes, markup and an ampersand" do + let(:hostile) { %(v1.0.0 "<>&') } + let(:encoded) { "v1.0.0%20%22%3C%3E%26%27" } + + before do + stub_const("PotatoMesh::Application::APP_VERSION", hostile) + end + + %w[/ /charts].each do |path| + it "carries it URL-encoded in every ?v= query on #{path}, and nowhere raw" do + get path + + body = last_response.body + expect(body.scan("?v=").size).to be > 10 + expect(body.scan("?v=#{encoded}").size).to eq(body.scan("?v=").size) + expect(body).not_to include(hostile) + expect(body).to include(%(v1.0.0 "<>&')) + end + end + + it "serves script-safe import-map JSON, byte for byte the map the boot hashes" do + get "/" + + served = last_response.body[%r{}m, 1] + js_root = File.join(Sinatra::Application.settings.public_folder, "assets", "js") + expect(served).not_to match(/[<>&']/) + expect(JSON.parse(served)["imports"].values).to all(end_with("?v=#{encoded}")) + expect(served).to eq(PotatoMesh::App::AssetImportMap.json(js_root, hostile)) + end + end +end diff --git a/web/spec/og_image_spec.rb b/web/spec/og_image_spec.rb index 3adf08d5..623835a7 100644 --- a/web/spec/og_image_spec.rb +++ b/web/spec/og_image_spec.rb @@ -294,6 +294,60 @@ RSpec.describe PotatoMesh::OgImage do ENV["FERRUM_BROWSER_PATH"] = original if original end end + + # The variable names a child process sees when it is spawned the way + # Ferrum 0.18 spawns Chromium: +Process.spawn+ with Ferrum's +env+ + # option merged over nothing (no Xvfb in headless mode). + # + # @return [Array] the child's environment variable names. + def chromium_child_env_keys + require "ferrum" + require "open3" + env = Hash(Ferrum::Browser::Options.new(described_class.browser_options).env) + out, status = Open3.capture2(env, RbConfig.ruby, "-e", "print ENV.keys.join(10.chr)") + expect(status).to be_success + out.split("\n") + end + + it "starts Chromium without API_TOKEN or the app's other variables (SPEC HD5)" do + added = { "API_TOKEN" => "spec-secret", "LC_TIME" => "C", "TZ" => "UTC" } + originals = added.keys.to_h { |key| [key, ENV[key]] } + added.each { |key, value| ENV[key] = value } + begin + keys = chromium_child_env_keys + + expect(keys & %w[API_TOKEN XDG_DATA_HOME XDG_CONFIG_HOME INSTANCE_DOMAIN RACK_ENV]).to eq([]) + expect(%w[PATH LC_TIME TZ] - keys).to eq([]) + ensure + originals.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + end + end + + describe ".chromium_env" do + it "keeps what Chromium needs and unsets every other variable (SPEC HD5)" do + kept = { + "PATH" => "/usr/bin", "HOME" => "/home/app", "TMPDIR" => "/tmp/app", + "LANG" => "C.UTF-8", "LANGUAGE" => "en", "LC_ALL" => "C", "LC_TIME" => "C", "TZ" => "UTC", + "DISPLAY" => ":0", "WAYLAND_DISPLAY" => "wayland-0", "XAUTHORITY" => "/x", "XDG_RUNTIME_DIR" => "/run/user/1", + "FONTCONFIG_FILE" => "/f", "FONTCONFIG_PATH" => "/fp", "CHROME_DEVEL_SANDBOX" => "/s", + "http_proxy" => "http://p", "https_proxy" => "http://p", "all_proxy" => "http://p", "no_proxy" => "localhost", + "HTTP_PROXY" => "http://p", "HTTPS_PROXY" => "http://p", "ALL_PROXY" => "http://p", "NO_PROXY" => "localhost", + } + dropped = { + "API_TOKEN" => "secret", "XDG_DATA_HOME" => "/d", "XDG_CONFIG_HOME" => "/c", "XDG_CACHE_HOME" => "/k", + "INSTANCE_DOMAIN" => "mesh.example", "FEDERATION" => "1", "RUBYOPT" => "-rbundler/setup", + "BUNDLE_GEMFILE" => "/app/Gemfile", "LD_PRELOAD" => "/evil.so", "LCX" => "1", "path" => "/x", + } + + env = described_class.chromium_env(kept.merge(dropped)) + + expect(env).to eq(kept.merge(dropped.transform_values { nil })) + end + + it "reads the process environment by default" do + expect(described_class.chromium_env.keys).to match_array(ENV.keys) + end end describe ".default_capture" do diff --git a/web/spec/public_scheme_spec.rb b/web/spec/public_scheme_spec.rb new file mode 100644 index 00000000..6118063f --- /dev/null +++ b/web/spec/public_scheme_spec.rb @@ -0,0 +1,152 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" + +# The scheme of the public URLs (SPEC HD4): the one INSTANCE_DOMAIN names, +# else http or https from the forwarded headers as Rack ranks them, else the +# request's own. robots.txt, sitemap.xml, the canonical link, og:image and the +# OG capture all print it. +RSpec.describe "Public URL scheme" do + let(:app) { Sinatra::Application } + # What the nginx example forwards, plus a client's own Forwarded header, + # which nginx passes through unchanged. + let(:proxied) { { "HTTP_X_FORWARDED_PROTO" => "https", "HTTP_FORWARDED" => "proto=ws" } } + + describe "generated URLs" do + before do + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false) + PotatoMesh::OgImage.reset_for_tests! + end + + after { PotatoMesh::OgImage.reset_for_tests! } + + it "skips a Forwarded proto other than http or https for X-Forwarded-Proto" do + get "/robots.txt", {}, proxied + + expect(last_response.body).to include("Sitemap: https://spec.mesh.test/sitemap.xml") + end + + it "prints that scheme in the sitemap, the canonical link and og:image" do + get "/sitemap.xml", {}, proxied + expect(last_response.body).to include("https://spec.mesh.test/") + expect(last_response.body).not_to include("ws://") + + get "/", {}, proxied + expect(last_response.body).to include('') + expect(last_response.body).to include('') + expect(last_response.body).not_to include("ws://") + end + + it "navigates the OG capture to the https URL" do + urls = [] + PotatoMesh::OgImage.capture_strategy = lambda do |url| + urls << url + "PNG_BYTES" + end + + get "/og-image.png", {}, proxied + + expect(last_response).to be_ok + expect(urls).to eq(["https://spec.mesh.test"]) + end + + it "keeps the scheme INSTANCE_DOMAIN names whatever the headers say" do + stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SCHEME", "https") + + get "/robots.txt", {}, { "HTTP_X_FORWARDED_PROTO" => "http", "HTTP_FORWARDED" => "proto=http" } + + expect(last_response.body).to include("Sitemap: https://spec.mesh.test/sitemap.xml") + end + end + + describe "PotatoMesh::App::PublicScheme.from_instance_domain" do + it "returns the http or https scheme a URL names, in lower case" do + scheme = ->(raw) { PotatoMesh::App::PublicScheme.from_instance_domain(raw) } + + expect(scheme.call("https://mesh.example.org")).to eq("https") + expect(scheme.call(" HTTP://mesh.example.org:8080 ")).to eq("http") + end + + it "returns nil for a bare host, another scheme or nothing" do + scheme = ->(raw) { PotatoMesh::App::PublicScheme.from_instance_domain(raw) } + + expect(scheme.call("mesh.example.org")).to be_nil + expect(scheme.call("mesh.example.org:8080")).to be_nil + expect(scheme.call("ws://mesh.example.org")).to be_nil + expect(scheme.call("")).to be_nil + expect(scheme.call(nil)).to be_nil + end + end + + describe "PotatoMesh::App::PublicScheme.from_request" do + # The scheme for a request to +url+ with the given extra Rack env. + # + # @param env [Hash] extra Rack env entries (headers as +HTTP_*+ keys). + # @param url [String] the request URL, whose scheme is the fallback. + # @return [String] the resolved scheme. + def scheme_for(env = {}, url = "http://app.internal/") + PotatoMesh::App::PublicScheme.from_request(Rack::MockRequest.env_for(url).merge(env)) + end + + it "falls back to the request's scheme" do + expect(scheme_for).to eq("http") + expect(scheme_for({}, "https://app.internal/")).to eq("https") + end + + it "answers https for a TLS request or X-Forwarded-Ssl, as Rack does" do + expect(scheme_for("HTTPS" => "on")).to eq("https") + expect(scheme_for("HTTP_X_FORWARDED_SSL" => "on")).to eq("https") + end + + it "ranks the last Forwarded proto above X-Forwarded-Proto" do + expect(scheme_for("HTTP_FORWARDED" => "proto=http;for=1.2.3.4, proto=https", "HTTP_X_FORWARDED_PROTO" => "http")).to eq("https") + end + + it "skips a WebSocket or unknown proto, in every header" do + expect(scheme_for("HTTP_FORWARDED" => "proto=wss")).to eq("http") + expect(scheme_for("HTTP_X_FORWARDED_PROTO" => "https, ws")).to eq("https") + expect(scheme_for("HTTP_X_FORWARDED_PROTO" => "ftp", "HTTP_X_FORWARDED_SCHEME" => "https")).to eq("https") + expect(scheme_for({ "HTTP_X_FORWARDED_SCHEME" => "wss" }, "https://app.internal/")).to eq("https") + end + + it "ignores a Forwarded header Rack cannot parse" do + expect(scheme_for("HTTP_FORWARDED" => "proto=https;secret=1", "HTTP_X_FORWARDED_PROTO" => "http")).to eq("http") + end + + it "follows Rack's configured header priorities" do + original = [Rack::Request.forwarded_priority, Rack::Request.x_forwarded_proto_priority] + headers = { "HTTP_FORWARDED" => "proto=https", "HTTP_X_FORWARDED_PROTO" => "http", "HTTP_X_FORWARDED_SCHEME" => "https" } + begin + Rack::Request.forwarded_priority = [:unknown, :x_forwarded, :forwarded] + expect(scheme_for(headers)).to eq("http") + Rack::Request.x_forwarded_proto_priority = [:scheme, :proto, :unknown] + expect(scheme_for(headers)).to eq("https") + Rack::Request.x_forwarded_proto_priority = [:unknown] + expect(scheme_for(headers)).to eq("https") + Rack::Request.forwarded_priority = [] + expect(scheme_for(headers)).to eq("http") + ensure + Rack::Request.forwarded_priority, Rack::Request.x_forwarded_proto_priority = original + end + end + + it "answers https when the server names neither web scheme" do + expect(scheme_for("rack.url_scheme" => "ws")).to eq("https") + expect(scheme_for("rack.url_scheme" => nil)).to eq("https") + end + end +end diff --git a/web/spec/security_headers_spec.rb b/web/spec/security_headers_spec.rb new file mode 100644 index 00000000..e51ced85 --- /dev/null +++ b/web/spec/security_headers_spec.rb @@ -0,0 +1,294 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" +require "digest" +require "timeout" + +# Content-Security-Policy and Referrer-Policy (SPEC HD1-HD3): the policy every +# HTML view carries, the inline scripts it allows by hash, the responses that +# carry no policy, and the middleware itself. +RSpec.describe "Security headers" do + let(:app) { Sinatra::Application } + let(:referrer_policy) { "strict-origin-when-cross-origin" } + + # The Leaflet release the layout loads, the one unpkg source the policy + # admits (SPEC HD1). + let(:leaflet_source) { "https://unpkg.com/leaflet@1.9.4/" } + + # The policy of SPEC HD1 with the given +'sha256-...'+ script sources. + # + # @param hashes [Array] quoted hash sources, in policy order. + # @return [String] the expected header value. + def expected_policy(hashes) + [ + "default-src 'self'", + (["script-src 'self' #{leaflet_source}"] + hashes).join(" "), + "style-src 'self' #{leaflet_source} 'unsafe-inline'", + "style-src-elem 'self' #{leaflet_source}", + "style-src-attr 'unsafe-inline'", + "img-src 'self' data: https:", + "connect-src 'self'", + "font-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", + ].join("; ") + end + + # The CSP source for a script body, as a browser computes it. + # + # @param body [String] the script element's text, byte for byte. + # @return [String] the quoted +'sha256-'+ source. + def hash_source(body) + "'sha256-#{Digest::SHA256.base64digest(body)}'" + end + + # Every inline script the policy governs, exactly as served: a ++, ++), as HTML parses them (SPEC CQ4). + # + # @param html [String] the served page. + # @return [Array] attributes and body of each script. + def inline_scripts(html) + html.scan(%r{]*)>(.*?)]*>}im).reject do |attrs, _body| + attrs.match?(/\bsrc\s*=/i) || attrs.match?(%r{application/ld\+json}i) + end + end + + describe "the inline script finder" do + it "finds a script tag in any letter case, with any end-tag form (SPEC CQ4)" do + html = <<~HTML + + + + + + HTML + + expect(inline_scripts(html).map(&:last)).to eq(["upper()", "a()", "mixed()"]) + end + end + + describe "HTML views" do + let(:node_id) { "!c5f0a001" } + + # Open the test database read-write. + # + # @yieldparam db [SQLite3::Database] the handle. + # @return [void] + def with_db + db = SQLite3::Database.new(PotatoMesh::Config.db_path) + db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms + yield db + ensure + db&.close + end + + before do + allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true) + PotatoMesh::App::Pages.clear_pages_cache! + now = Time.now.to_i + with_db do |db| + db.execute( + "INSERT OR REPLACE INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard) VALUES (?,?,?,?,?,?)", + [node_id, 0xc5f0a001, "CSP", "Policy Probe", now, now], + ) + end + end + + after do + with_db { |db| db.execute("DELETE FROM nodes WHERE node_id = ?", [node_id]) } + PotatoMesh::App::Pages.clear_pages_cache! + end + + %w[/ /map /chat /nodes /charts /federation /pages/about /nodes/!c5f0a001].each do |path| + it "sends the policy and the referrer policy on #{path}, its one inline script allowed by hash" do + get path + + expect(last_response).to be_ok + scripts = inline_scripts(last_response.body) + expect(scripts.map(&:first)).to eq([' type="importmap"']) + expect(last_response.headers["Content-Security-Policy"]).to eq(expected_policy([hash_source(scripts.first.last)])) + expect(last_response.headers["Referrer-Policy"]).to eq(referrer_policy) + end + end + + it "boots the charts, node and federation views from modules the policy allows by origin" do + { "/charts" => "charts-page-boot.js", "/nodes/!c5f0a001" => "node-page-boot.js", + "/federation" => "federation-page-boot.js" }.each do |path, boot| + get path + + expect(last_response.body).to match(%r{}) + end + end + + it "builds the policy at boot: a request hashes nothing" do + allow(PotatoMesh::App::SecurityHeaders).to receive(:content_security_policy).and_call_original + allow(PotatoMesh::App::SecurityHeaders).to receive(:script_hash_source).and_call_original + + get "/" + + expect(last_response.headers["Content-Security-Policy"]).to include("'sha256-") + expect(PotatoMesh::App::SecurityHeaders).not_to have_received(:content_security_policy) + expect(PotatoMesh::App::SecurityHeaders).not_to have_received(:script_hash_source) + end + + it "sends the policy on an HTML error page too" do + get "/no-such-page" + + expect(last_response.status).to eq(404) + expect(last_response.headers["Content-Security-Policy"]).to start_with("default-src 'self'; script-src ") + expect(last_response.headers["Referrer-Policy"]).to eq(referrer_policy) + end + end + + describe "responses that are not HTML" do + before do + PotatoMesh::OgImage.reset_for_tests! + PotatoMesh::OgImage.capture_strategy = ->(_) { "PNG_BYTES" } + allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(false) + end + + after { PotatoMesh::OgImage.reset_for_tests! } + + { + "/api/nodes" => "application/json", + "/version" => "application/json", + "/robots.txt" => "text/plain", + "/sitemap.xml" => "application/xml", + "/metrics" => "text/plain", + "/og-image.png" => "image/png", + "/assets/js/app/main.js" => "javascript", + "/assets/styles/base.css" => "text/css", + "/potatomesh-logo.svg" => "image/svg+xml", + }.each do |path, type| + it "sends the referrer policy and no CSP on #{path}" do + get path + + expect(last_response.status).to eq(200) + expect(last_response.content_type).to include(type) + expect(last_response.headers["Content-Security-Policy"]).to be_nil + expect(last_response.headers["Referrer-Policy"]).to eq(referrer_policy) + end + end + + it "sends the referrer policy and no CSP on the live-update stream" do + allow(PotatoMesh::App::Routes::Events).to receive(:pump) + + Timeout.timeout(5) { get "/api/events" } + + expect(last_response.content_type).to include("text/event-stream") + expect(last_response.headers["Content-Security-Policy"]).to be_nil + expect(last_response.headers["Referrer-Policy"]).to eq(referrer_policy) + ensure + PotatoMesh::App::PubSub.reset! + end + end + + describe "the middleware" do + let(:policy) { "default-src 'self'" } + + # Run the middleware over a downstream response with the given headers. + # + # @param headers [Hash] the downstream response headers. + # @return [Hash] the headers the middleware returns. + def headers_after(headers) + downstream = ->(_env) { [200, headers, ["body"]] } + PotatoMesh::App::SecurityHeaders.new(downstream, content_security_policy: policy).call({})[1] + end + + it "adds the policy to text/html whatever the key's case and the parameters" do + expect(headers_after("Content-Type" => "TEXT/HTML; charset=utf-8")["content-security-policy"]).to eq(policy) + expect(headers_after("content-type" => "text/html")["content-security-policy"]).to eq(policy) + end + + it "adds no policy to other types, or without a type" do + expect(headers_after("content-type" => "text/htmlx")).not_to have_key("content-security-policy") + expect(headers_after("content-type" => "application/xhtml+xml")).not_to have_key("content-security-policy") + expect(headers_after({})).not_to have_key("content-security-policy") + end + + it "adds the referrer policy to every response" do + expect(headers_after({})["referrer-policy"]).to eq(referrer_policy) + expect(headers_after("content-type" => "application/json")["referrer-policy"]).to eq(referrer_policy) + end + + it "keeps a policy or referrer policy set further in" do + headers = headers_after( + "content-type" => "text/html", + "Content-Security-Policy" => "default-src 'none'", + "Referrer-Policy" => "no-referrer", + ) + + expect(headers).not_to have_key("content-security-policy") + expect(headers).not_to have_key("referrer-policy") + expect(headers["Content-Security-Policy"]).to eq("default-src 'none'") + expect(headers["Referrer-Policy"]).to eq("no-referrer") + end + end + + describe "the Leaflet source" do + # The sources of one directive of +policy+. + # + # @param policy [String] a Content-Security-Policy value. + # @param directive [String] the directive name. + # @return [Array] its sources. + def sources_of(policy, directive) + policy.split("; ").find { |entry| entry.start_with?("#{directive} ") }.split(" ").drop(1) + end + + it "is the package release of the pinned asset URLs, never the whole host" do + policy = PotatoMesh::App::SecurityHeaders.content_security_policy([]) + + expect(PotatoMesh::App::LeafletAssets.sources).to eq([leaflet_source]) + %w[script-src style-src style-src-elem].each do |directive| + expect(sources_of(policy, directive)).to include(leaflet_source) + expect(sources_of(policy, directive)).not_to include("https://unpkg.com", "https://unpkg.com/") + end + end + + it "covers exactly the stylesheet and script the layout loads" do + get "/" + + css = last_response.body[%r{(url) { PotatoMesh::App::LeafletAssets.package_source(url) } + + expect(source.call("https://unpkg.com/leaflet@1.9.4/dist/leaflet.js")).to eq(leaflet_source) + expect(source.call("https://cdn.example.test/pkg@2.0.0/a/b.css")).to eq("https://cdn.example.test/pkg@2.0.0/") + end + end + + describe ".content_security_policy" do + it "lists the two script origins, then each inline script's hash once" do + policy = PotatoMesh::App::SecurityHeaders.content_security_policy(["a", "b", "a"]) + + expect(policy).to eq(expected_policy([hash_source("a"), hash_source("b")])) + end + + it "hashes a script body byte for byte" do + expect(PotatoMesh::App::SecurityHeaders.script_hash_source(%({"imports":{}}))).to eq(hash_source(%({"imports":{}}))) + end + end +end diff --git a/web/views/charts.erb b/web/views/charts.erb index 3cab9b43..afe1c91c 100644 --- a/web/views/charts.erb +++ b/web/views/charts.erb @@ -22,7 +22,6 @@

Loading aggregated telemetry charts…

- +<%# A boot module, not an inline script: the Content-Security-Policy allows + it by origin (SPEC HD3). %> + diff --git a/web/views/federation.erb b/web/views/federation.erb index ebf61bd2..2ea404fd 100644 --- a/web/views/federation.erb +++ b/web/views/federation.erb @@ -21,7 +21,6 @@ <%= erb :"shared/_instances_table" %> - +<%# A boot module, not an inline script: the Content-Security-Policy allows + it by origin (SPEC HD3). %> + diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 510377ab..2bbbe7a5 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -112,10 +112,12 @@ + <%# The pinned URLs and hashes live in LeafletAssets, from which the + Content-Security-Policy derives the one unpkg source it admits (SPEC HD1). %> <%# `defer` so this external script does not block first paint on the unpkg @@ -124,8 +126,8 @@ is ready by the time the app initialises (a missing L still degrades to the "map unavailable" placeholder). %> @@ -172,7 +174,7 @@

- <%= site_name %> + <%= Rack::Utils.escape_html(site_name) %>

<%# Header glyphs (region, menu, close) are inline SVG drawn in the @@ -335,10 +337,6 @@ - diff --git a/web/views/node_detail.erb b/web/views/node_detail.erb index 1813050d..e39640ee 100644 --- a/web/views/node_detail.erb +++ b/web/views/node_detail.erb @@ -39,7 +39,6 @@

This page requires JavaScript to display node information.

- +<%# A boot module, not an inline script: the Content-Security-Policy allows + it by origin (SPEC HD3). %> + diff --git a/web/views/shared/_footer.erb b/web/views/shared/_footer.erb index 90323853..7759e122 100644 --- a/web/views/shared/_footer.erb +++ b/web/views/shared/_footer.erb @@ -38,7 +38,7 @@ PotatoMesh <% if version && !version.empty? %> - <%= version %> + <%= Rack::Utils.escape_html(version) %> <% end %>
<%# The links are their own footer tier (Post-Deploy review 02): flex-basis