Skip to content

Commit cbafc98

Browse files
committed
fix: allow paths for analytics url
1 parent 53d6f6e commit cbafc98

5 files changed

Lines changed: 69 additions & 11 deletions

File tree

apps/core/src/core/schemas/organization_analytics.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@ def _must_be_https_and_clean(cls, v: AnyHttpUrl) -> AnyHttpUrl:
3939
# would either fail (mixed content) or downgrade privacy.
4040
if v.scheme != "https":
4141
raise ValueError("Matomo URL must use https://")
42-
# No query / fragment. Path other than "/" is suspicious — the JS
43-
# snippet appends "matomo.php" / "matomo.js" to the base.
42+
# No query / fragment — the JS snippet appends "matomo.php" /
43+
# "matomo.js" to the base. A path is fine (self-hosted Matomo often
44+
# lives under one, e.g. https://host.de/matomo/); normalize it to a
45+
# trailing slash so the append yields ".../matomo/matomo.js".
4446
if v.query or v.fragment:
4547
raise ValueError("Matomo URL must not contain a query or fragment")
4648
path = v.path or "/"
47-
if path not in ("", "/"):
48-
raise ValueError("Matomo URL must point at the instance root")
49+
if not path.endswith("/"):
50+
return AnyHttpUrl(f"{v}/")
4951
return v
5052

5153

apps/core/tests/unit/schemas/test_organization_analytics_schemas.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ def test_create_rejects_http_url() -> None:
4848
OrganizationAnalyticsCreate.model_validate(payload)
4949

5050

51+
def test_create_accepts_subdirectory_url() -> None:
52+
"""Self-hosted Matomo often lives under a path, e.g. host.de/matomo/."""
53+
payload = _valid_payload()
54+
payload["config"]["url"] = "https://analytics.example.org/matomo/"
55+
parsed = OrganizationAnalyticsCreate.model_validate(payload)
56+
assert str(parsed.config.url) == "https://analytics.example.org/matomo/"
57+
58+
59+
def test_create_normalizes_missing_trailing_slash() -> None:
60+
"""The tracker appends matomo.php/matomo.js to the base, so the stored
61+
URL must end with a slash."""
62+
payload = _valid_payload()
63+
payload["config"]["url"] = "https://analytics.example.org/matomo"
64+
parsed = OrganizationAnalyticsCreate.model_validate(payload)
65+
assert str(parsed.config.url) == "https://analytics.example.org/matomo/"
66+
67+
68+
def test_create_still_rejects_query_and_fragment() -> None:
69+
for bad in (
70+
"https://analytics.example.org/matomo/?x=1",
71+
"https://analytics.example.org/matomo/#frag",
72+
):
73+
payload = _valid_payload()
74+
payload["config"]["url"] = bad
75+
with pytest.raises(ValidationError):
76+
OrganizationAnalyticsCreate.model_validate(payload)
77+
78+
5179
def test_read_defaults_usage_count_to_zero() -> None:
5280
read = OrganizationAnalyticsRead.model_validate(
5381
{

apps/web/components/analytics/MatomoTracker.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import Script from "next/script";
66
* Injects the customer's Matomo tracker on a public dashboard.
77
*
88
* Defence-in-depth on the URL/site_id: the backend already validates
9-
* https-only, no path, and numeric site IDs. We re-validate here at the
10-
* trust boundary so a malformed or hostile JSONB row from the DB can't
11-
* end up in a <script> tag.
9+
* https-only, no query/fragment, and numeric site IDs. We re-validate here
10+
* at the trust boundary so a malformed or hostile JSONB row from the DB
11+
* can't end up in a <script> tag.
1212
*
1313
* Sends:
1414
* - setDocumentTitle(projectName) — readable in Matomo's UI
@@ -34,8 +34,10 @@ function isSafeMatomoUrl(u: string): boolean {
3434
try {
3535
const parsed = new URL(u);
3636
if (parsed.protocol !== "https:") return false;
37-
if (parsed.search || parsed.hash) return false;
38-
return parsed.pathname === "/" || parsed.pathname === "";
37+
// A pathname is allowed — self-hosted Matomo often lives under one
38+
// (e.g. https://host.de/matomo/); the base is normalized to a trailing
39+
// slash before matomo.php/matomo.js are appended.
40+
return !parsed.search && !parsed.hash;
3941
} catch {
4042
return false;
4143
}

apps/web/lib/validations/__tests__/organizationAnalytics.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ describe("organizationAnalyticsCreateSchema", () => {
3333
).toThrow();
3434
});
3535

36+
it("accepts a subdirectory url and normalizes the trailing slash", () => {
37+
const parsed = organizationAnalyticsCreateSchema.parse({
38+
...validCreate,
39+
config: { ...validCreate.config, url: "https://analytics.example.org/matomo" },
40+
});
41+
expect(parsed.config.url).toBe("https://analytics.example.org/matomo/");
42+
});
43+
44+
it("still rejects urls with query or fragment", () => {
45+
for (const bad of [
46+
"https://analytics.example.org/matomo/?x=1",
47+
"https://analytics.example.org/matomo/#frag",
48+
]) {
49+
expect(() =>
50+
organizationAnalyticsCreateSchema.parse({
51+
...validCreate,
52+
config: { ...validCreate.config, url: bad },
53+
})
54+
).toThrow();
55+
}
56+
});
57+
3658
it("rejects http urls", () => {
3759
expect(() =>
3860
organizationAnalyticsCreateSchema.parse({

apps/web/lib/validations/organizationAnalytics.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ export const matomoConfigSchema = z.object({
1414
.refine((v) => {
1515
try {
1616
const u = new URL(v);
17-
return (u.pathname === "" || u.pathname === "/") && !u.search && !u.hash;
17+
return !u.search && !u.hash;
1818
} catch {
1919
return false;
2020
}
21-
}, "Must point at the Matomo root (no path, query, or fragment)"),
21+
}, "Must not contain a query or fragment")
22+
// A path is fine (self-hosted Matomo often lives under one, e.g.
23+
// https://host.de/matomo/); the tracker appends matomo.php/matomo.js,
24+
// so normalize to a trailing slash.
25+
.transform((v) => (v.endsWith("/") ? v : `${v}/`)),
2226
site_id: z
2327
.string()
2428
.min(1)

0 commit comments

Comments
 (0)