Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bedrock/base/templates/base-protocol.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<!doctype html>
{# Note the "windows" class, without javascript platform-specific assets default to windows #}
<html class="windows no-js" lang="{{ LANG|replace('en-US', 'en') }}" dir="{{ DIR }}" data-country-code="{{ country_code }}" data-needs-consent="{{ needs_data_consent(country_code) }}" data-latest-firefox="{{ latest_firefox_version }}" data-esr-versions="{{ esr_firefox_versions|join(' ') }}" {% if settings.GTM_CONTAINER_ID %}data-gtm-container-id="{{ settings.GTM_CONTAINER_ID }}"{% endif %} {% if settings.STUB_ATTRIBUTION_RATE %}data-stub-attribution-rate="{{ settings.STUB_ATTRIBUTION_RATE }}"{% endif %} {% if settings.SENTRY_FRONTEND_DSN %}data-sentry-dsn="{{ settings.SENTRY_FRONTEND_DSN }}"{% endif %} {% block html_attrs %}{% endblock %}>
<html class="windows no-js" lang="{{ LANG|replace('en-US', 'en') }}" dir="{{ DIR }}" data-country-code="{{ country_code }}" data-needs-consent="{{ needs_data_consent(country_code) }}" data-latest-firefox="{{ latest_firefox_version }}" data-esr-versions="{{ esr_firefox_versions|join(' ') }}" {% if settings.GTM_CONTAINER_ID %}data-gtm-container-id="{{ settings.GTM_CONTAINER_ID }}"{% endif %} {% if settings.GTM_SERVER_URL %}data-gtm-server-url="{{ settings.GTM_SERVER_URL }}" data-gtm-server-path="{{ settings.GTM_SERVER_PATH }}"{% endif %} {% if settings.STUB_ATTRIBUTION_RATE %}data-stub-attribution-rate="{{ settings.STUB_ATTRIBUTION_RATE }}"{% endif %} {% if settings.SENTRY_FRONTEND_DSN %}data-sentry-dsn="{{ settings.SENTRY_FRONTEND_DSN }}"{% endif %} {% block html_attrs %}{% endblock %}>
<head>
<meta charset="utf-8">{# Note: Must be within first 512 bytes of page #}

Expand Down
41 changes: 40 additions & 1 deletion bedrock/base/tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import pytest

from bedrock.settings.base import _get_media_cdn_hostname_for_storage_backend
from bedrock.settings.base import _get_media_cdn_hostname_for_storage_backend, _normalize_gtm_server_path, _normalize_gtm_server_url


@override_settings(DEV=False, PROD_LANGUAGES=("de", "fr", "nb-NO", "ja", "ja-JP-mac", "en-US", "en-GB"))
Expand All @@ -33,3 +33,42 @@ def test_lang_groups():
)
def test_get_media_cdn_hostname(media_url, expected_hostname):
assert _get_media_cdn_hostname_for_storage_backend(media_url) == expected_hostname


@pytest.mark.parametrize(
"raw_url, expected_url",
(
("https://gtm.mozilla.org", "https://gtm.mozilla.org"),
("https://gtm.mozilla.org/", "https://gtm.mozilla.org"),
# A scheme-less value would resolve page-relative in the browser.
("gtm.mozilla.org", "https://gtm.mozilla.org"),
("gtm.mozilla.org/", "https://gtm.mozilla.org"),
# Protocol-relative is not a valid CSP source expression.
("//gtm.mozilla.org", "https://gtm.mozilla.org"),
# http:// would be mixed content on our https pages.
("http://gtm.mozilla.org", "https://gtm.mozilla.org"),
("http://gtm.mozilla.org/", "https://gtm.mozilla.org"),
("", ""), # unset, which disables server-side dependency serving
# A scheme with no host is treated as unset rather than becoming garbage.
("https://", ""),
("//", ""),
),
)
def test_normalize_gtm_server_url(raw_url, expected_url):
assert _normalize_gtm_server_url(raw_url) == expected_url


@pytest.mark.parametrize(
"raw_path, expected_path",
(
("/gtm.js", "/gtm.js"), # the default
("gtm.js", "/gtm.js"),
# A custom "Tag serving path" needs its trailing slash kept: the tagging
# server answers /script/ but 400s on /script.
("/script/", "/script/"),
("script/", "/script/"),
("", ""),
),
)
def test_normalize_gtm_server_path(raw_path, expected_path):
assert _normalize_gtm_server_path(raw_path) == expected_path
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
{% endif %}

<!doctype html>
<html class="windows no-js" lang="{{ LANG|replace('en-US', 'en') }}" dir="{{ DIR }}" data-country-code="{{ country_code }}" data-needs-consent="{{ needs_data_consent(country_code) }}" data-latest-firefox="{{ latest_firefox_version }}" data-esr-versions="{{ esr_firefox_versions|join(' ') }}" {% if settings.GTM_CONTAINER_ID %}data-gtm-container-id="{{ settings.GTM_CONTAINER_ID }}" {% endif %} {% if settings.SENTRY_FRONTEND_DSN %}data-sentry-dsn="{{ settings.SENTRY_FRONTEND_DSN }}" {% endif %} {% block html_attrs %}{% endblock %}>
<html class="windows no-js" lang="{{ LANG|replace('en-US', 'en') }}" dir="{{ DIR }}" data-country-code="{{ country_code }}" data-needs-consent="{{ needs_data_consent(country_code) }}" data-latest-firefox="{{ latest_firefox_version }}" data-esr-versions="{{ esr_firefox_versions|join(' ') }}" {% if settings.GTM_CONTAINER_ID %}data-gtm-container-id="{{ settings.GTM_CONTAINER_ID }}" {% endif %} {% if settings.GTM_SERVER_URL %}data-gtm-server-url="{{ settings.GTM_SERVER_URL }}" data-gtm-server-path="{{ settings.GTM_SERVER_PATH }}" {% endif %} {% if settings.SENTRY_FRONTEND_DSN %}data-sentry-dsn="{{ settings.SENTRY_FRONTEND_DSN }}" {% endif %} {% block html_attrs %}{% endblock %}>

<head>
<meta charset="utf-8">
Expand Down
12 changes: 12 additions & 0 deletions bedrock/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@
if TRANSCEND_AIRGAP_URL: # noqa: F405
_csp_style_src.add(csp.constants.UNSAFE_INLINE)

# When server-side GTM is enabled, our own tagging server serves gtm.js
# (`script-src`) and receives measurement hits. Google documents `connect-src`,
# `img-src` and `frame-src` as all required for the tagging server URL, since hits
# fall back from fetch to an image beacon, and some tags redirect via an iframe.
# See https://developers.google.com/tag-platform/tag-manager/server-side/send-data
# GTM_SERVER_URL is an origin with no path.
if GTM_SERVER_URL:
_csp_script_src.add(GTM_SERVER_URL)
_csp_connect_src.add(GTM_SERVER_URL)
_csp_img_src.add(GTM_SERVER_URL)
_csp_frame_src.add(GTM_SERVER_URL)

# 2. TEST-SPECIFIC SETTINGS
# TODO: make this selectable by an env var, like the other modes
if (len(sys.argv) > 1 and sys.argv[1] == "test") or "pytest" in sys.modules:
Expand Down
27 changes: 27 additions & 0 deletions bedrock/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,33 @@ def _is_bedrock_custom_app(app_name):

GTM_CONTAINER_ID = config("GTM_CONTAINER_ID", default="")


def _normalize_gtm_server_url(url):
# Coerce to an https:// origin so the front-end can append a path and CSP
# gets a valid source. A bare hostname is the likely mistake here, since the
# adjacent CSP entries and CSP_CONNECT_SRC are all written that way, and it
# would resolve page-relative in the browser and 404 without a CSP warning.
host = url.removeprefix("https://").removeprefix("http://").removeprefix("//").rstrip("/")
return f"https://{host}" if host else ""


# When empty, Tag Manager dependencies load from Google as usual.
GTM_SERVER_URL = _normalize_gtm_server_url(config("GTM_SERVER_URL", default=""))


def _normalize_gtm_server_path(path):
# The server container's "Tag serving path". Kept separate from
# GTM_SERVER_URL so that stays a bare origin for CSP, and taken verbatim
# apart from a leading slash, because the trailing slash is significant and
# differs by path: "/script/" 400s without it, "/gtm.js" 400s with it.
path = path.strip()
if path and not path.startswith("/"):
path = f"/{path}"
return path


GTM_SERVER_PATH = _normalize_gtm_server_path(config("GTM_SERVER_PATH", default="/gtm.js"))

# Transcend Consent Management - airgap.js script URL
TRANSCEND_AIRGAP_URL = config("TRANSCEND_AIRGAP_URL", default="")

Expand Down
17 changes: 13 additions & 4 deletions media/js/base/gtm/gtm-snippet.es6.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,18 @@ import {
setGtagAnalyticsConsentMode
} from '../consent/utils.es6';

const GTM_CONTAINER_ID = document
.getElementsByTagName('html')[0]
.getAttribute('data-gtm-container-id');
const html = document.getElementsByTagName('html')[0];

const GTM_CONTAINER_ID = html.getAttribute('data-gtm-container-id');

// Load Tag Manager from our own tagging server when server-side dependency
// serving is configured, else from Google. The tagging server serves gtm.js from
// whatever path is set as its "Tag serving path", so that is configurable too,
// but Google's CDN only ever serves it from /gtm.js.
const GTM_SERVER_URL = html.getAttribute('data-gtm-server-url');
const GTM_BASE_URL = GTM_SERVER_URL || 'https://www.googletagmanager.com';
const GTM_SCRIPT_PATH =
(GTM_SERVER_URL && html.getAttribute('data-gtm-server-path')) || '/gtm.js';

const GTMSnippet = {};

Expand Down Expand Up @@ -60,7 +69,7 @@ GTMSnippet.loadSnippet = () => {
// prettier-ignore
(function(w,d,s,l,i,j,f,dl,k,q){
w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});f=d.getElementsByTagName(s)[0];
k=i.length;q='//www.googletagmanager.com/gtm.js?id=@&l='+(l||'dataLayer');
k=i.length;q=GTM_BASE_URL+GTM_SCRIPT_PATH+'?id=@&l='+(l||'dataLayer');
while(k--){j=d.createElement(s);j.async=!0;j.src=q.replace('@',i[k]);f.parentNode.insertBefore(j,f);}
}(window,document,'script','dataLayer',[GTM_CONTAINER_ID]));
}
Expand Down
Loading