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
16 changes: 15 additions & 1 deletion packages/core/src/credentials.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion python/src/geolibre/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Comment on lines +151 to 157
plugins = safe.get("plugins")
Expand Down
24 changes: 24 additions & 0 deletions python/tests/test_scripting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +409 to +412
},
}
]
}
)
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):
Expand Down
19 changes: 19 additions & 0 deletions tests/project-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { arbitrary: "too-deep-secret" };
for (let index = 0; index < 12; index += 1) nested = { child: nested };
Expand Down
Loading