diff --git a/packages/core/src/credentials.ts b/packages/core/src/credentials.ts index 283c76c9b..0b5cfa587 100644 --- a/packages/core/src/credentials.ts +++ b/packages/core/src/credentials.ts @@ -1,4 +1,4 @@ -import type { GeoLibreProject } from "./types"; +import type { GeoLibreProject, LayerConnection } from "./types"; /** * Project fields whose values are credentials. Keep this registry as the @@ -279,6 +279,20 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe ) as string, } : {}), + // `connection.lastError` is free-form text taken from a caught error, and a + // refresh path that words it as `Failed to fetch ${url}` would carry the + // request's credential parameters. Sweeping it costs nothing and keeps the + // no-secret guarantee from depending on how an error message is phrased. + ...(layer.connection + ? { + connection: redactConfigurationValue( + layer.connection, + `layers[${index}].connection`, + redactedPaths, + redactedCount, + ) as LayerConnection, + } + : {}), })); let plugins = project.plugins; diff --git a/python/src/geolibre/project.py b/python/src/geolibre/project.py index 273f24ac6..966d8a28c 100644 --- a/python/src/geolibre/project.py +++ b/python/src/geolibre/project.py @@ -148,7 +148,11 @@ def redact_credentials(project: dict[str, Any]) -> dict[str, Any]: for layer in layers: if not isinstance(layer, dict): continue - for field in ("source", "metadata", "sourcePath"): + # `connection.lastError` is free-form text taken from a caught + # error, which a future refresh path could easily build from the + # request URL. Sweeping it costs nothing and keeps the no-secret + # guarantee from depending on how an error message is worded. + for field in ("source", "metadata", "sourcePath", "connection"): if field in layer: layer[field] = _redact_config(layer[field]) plugins = safe.get("plugins") diff --git a/python/tests/test_scripting.py b/python/tests/test_scripting.py index b8b00c89b..c179adf6d 100644 --- a/python/tests/test_scripting.py +++ b/python/tests/test_scripting.py @@ -397,6 +397,30 @@ def test_python_credential_field_registry_matches_js(): assert safe["layers"][0]["source"] == {"sr": 4326, "key": "layer-identifier"} +def test_python_redaction_sweeps_layer_connection(): + """`connection.lastError` is free-form error text and must be swept too.""" + safe = redact_credentials( + { + "layers": [ + { + "source": {"url": "https://example.com/tiles"}, + "connection": { + "layerId": "auth", + "interval": 300, + "lastSyncedAt": "2026-01-01T00:00:00.000Z", + "lastError": "Failed to fetch https://example.com/tiles?token=py-connection-secret", + "onFailure": "keep-last", + }, + } + ] + } + ) + connection = safe["layers"][0]["connection"] + assert "py-connection-secret" not in str(safe) + assert connection["lastError"] == "Failed to fetch https://example.com/tiles" + assert connection["interval"] == 300 + + def test_python_redaction_fails_closed_at_depth_limit(): nested = {"password": "too-deep-secret"} for _ in range(12): diff --git a/tests/project-credentials.test.ts b/tests/project-credentials.test.ts index b1a441e3c..ed8122dc3 100644 --- a/tests/project-credentials.test.ts +++ b/tests/project-credentials.test.ts @@ -139,6 +139,25 @@ describe("project credential redaction", () => { assert.deepEqual(safe.layers[0].source, { sr: 4326, key: "layer-identifier" }); }); + it("sweeps a layer's connection record, not only its source", () => { + // `lastError` is free-form text from a caught error, so a refresh path that + // words it with the request URL must not carry the credential out. + const project = credentialProject(); + project.layers[0].connection = { + layerId: "auth", + interval: 300, + lastSyncedAt: "2026-01-01T00:00:00.000Z", + lastError: "Failed to fetch https://example.com/tiles?token=connection-secret", + onFailure: "keep-last", + }; + + const { project: safe, redactedPaths } = redactProjectCredentials(project); + assert.ok(!serializeProject(safe).includes("connection-secret")); + assert.equal(safe.layers[0].connection?.lastError, "Failed to fetch https://example.com/tiles"); + assert.equal(safe.layers[0].connection?.interval, 300); + assert.ok(redactedPaths.includes("layers[0].connection.lastError")); + }); + it("fails closed when configuration exceeds the traversal depth", () => { let nested: Record = { arbitrary: "too-deep-secret" }; for (let index = 0; index < 12; index += 1) nested = { child: nested };