Skip to content

Commit 3d97b67

Browse files
committed
Close remaining credential egress gaps
- redact geocoder endpoints in TypeScript and Python - fail closed at traversal depth in both implementations - detach inline GeoJSON in redacted projects
1 parent b919a15 commit 3d97b67

4 files changed

Lines changed: 71 additions & 20 deletions

File tree

packages/core/src/credentials.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,6 @@ function redactConfigurationValue(
132132
redactedCount: { value: number },
133133
depth = 0,
134134
): unknown {
135-
if (typeof value === "string") {
136-
const redacted = redactUrlCredentials(value);
137-
if (redacted !== value) {
138-
redactedPaths.push(path);
139-
redactedCount.value += 1;
140-
}
141-
return redacted;
142-
}
143135
if (depth >= MAX_REDACT_DEPTH) {
144136
// Fail closed. A deeply nested configuration shape is not needed to render
145137
// any built-in layer, and returning it unchanged would let a credential
@@ -148,12 +140,21 @@ function redactConfigurationValue(
148140
redactedCount.value += 1;
149141
return undefined;
150142
}
143+
if (typeof value === "string") {
144+
const redacted = redactUrlCredentials(value);
145+
if (redacted !== value) {
146+
redactedPaths.push(path);
147+
redactedCount.value += 1;
148+
}
149+
return redacted;
150+
}
151151
if (Array.isArray(value)) {
152152
return value.map((item, index) =>
153153
redactConfigurationValue(item, `${path}[${index}]`, redactedPaths, redactedCount, depth + 1),
154154
);
155155
}
156-
if (!isPlainObject(value) || isGeoJsonPayload(value)) return value;
156+
if (!isPlainObject(value)) return value;
157+
if (isGeoJsonPayload(value)) return structuredClone(value);
157158

158159
const result: Record<string, unknown> = {};
159160
for (const [key, nested] of Object.entries(value)) {
@@ -206,14 +207,23 @@ export function redactProjectCredentials(project: GeoLibreProject): CredentialRe
206207
redactedPaths.push("basemapStyleUrl");
207208
redactedCount.value += 1;
208209
}
209-
const preferences = project.preferences
210-
? {
211-
...project.preferences,
212-
environmentVariables: [],
213-
geocoding: project.preferences.geocoding
214-
? { ...project.preferences.geocoding, apiKeys: {} }
215-
: project.preferences.geocoding,
210+
const geocoding = project.preferences?.geocoding
211+
? { ...project.preferences.geocoding, apiKeys: {} }
212+
: project.preferences?.geocoding;
213+
if (geocoding) {
214+
for (const field of ["forwardEndpoint", "reverseEndpoint"] as const) {
215+
const endpoint = geocoding[field];
216+
if (typeof endpoint !== "string") continue;
217+
const redacted = redactUrlCredentials(endpoint);
218+
if (redacted !== endpoint) {
219+
geocoding[field] = redacted;
220+
redactedPaths.push(`preferences.geocoding.${field}`);
221+
redactedCount.value += 1;
216222
}
223+
}
224+
}
225+
const preferences = project.preferences
226+
? { ...project.preferences, environmentVariables: [], geocoding }
217227
: project.preferences;
218228
if (project.preferences?.environmentVariables?.length > 0) {
219229
redactedPaths.push("preferences.environmentVariables");

python/src/geolibre/project.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"st",
6464
"skoid",
6565
}
66+
_MAX_REDACT_DEPTH = 12
6667

6768

6869
def _redact_url(value: str) -> str:
@@ -97,18 +98,20 @@ def keep_params(params: str) -> str:
9798
)
9899

99100

100-
def _redact_config(value: Any) -> Any:
101+
def _redact_config(value: Any, depth: int = 0) -> Any:
101102
"""Return project configuration with credential-named fields removed."""
103+
if depth >= _MAX_REDACT_DEPTH:
104+
return None
102105
if isinstance(value, str):
103106
return _redact_url(value)
104107
if isinstance(value, list):
105-
return [_redact_config(item) for item in value]
108+
return [_redact_config(item, depth + 1) for item in value]
106109
if not isinstance(value, dict):
107110
return copy.deepcopy(value)
108111
if value.get("type") in {"FeatureCollection", "Feature", "GeometryCollection"}:
109112
return copy.deepcopy(value)
110113
return {
111-
key: _redact_config(nested)
114+
key: _redact_config(nested, depth + 1)
112115
for key, nested in value.items()
113116
if key.lower() not in _CREDENTIAL_FIELD_NAMES
114117
}
@@ -125,6 +128,9 @@ def redact_credentials(project: dict[str, Any]) -> dict[str, Any]:
125128
geocoding = preferences.get("geocoding")
126129
if isinstance(geocoding, dict):
127130
geocoding["apiKeys"] = {}
131+
for field in ("forwardEndpoint", "reverseEndpoint"):
132+
if isinstance(geocoding.get(field), str):
133+
geocoding[field] = _redact_url(geocoding[field])
128134
layers = safe.get("layers")
129135
if isinstance(layers, list):
130136
for layer in layers:

python/tests/test_scripting.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import geolibre.geolibre as gmod
1313
from geolibre.geolibre import Feature, Layer, Map
14+
from geolibre.project import redact_credentials
1415

1516

1617
@pytest.fixture
@@ -309,6 +310,7 @@ def test_python_project_egress_redacts_credentials(m, tmp_path):
309310
m.project["preferences"]["geocoding"] = {
310311
"providerId": "mapbox",
311312
"apiKeys": {"mapbox": "python-geocoder-secret"},
313+
"forwardEndpoint": "https://geocode.example.com?key=python-endpoint-secret",
312314
}
313315
m.project["layers"] = [
314316
{
@@ -341,6 +343,7 @@ def test_python_project_egress_redacts_credentials(m, tmp_path):
341343
for secret in (
342344
"python-env-secret",
343345
"python-geocoder-secret",
346+
"python-endpoint-secret",
344347
"password",
345348
"python-url-secret",
346349
"python-subscription-secret",
@@ -357,6 +360,14 @@ def test_python_project_egress_redacts_credentials(m, tmp_path):
357360
assert m.to_project(keep_credentials=True)["plugins"]["settings"]
358361

359362

363+
def test_python_redaction_fails_closed_at_depth_limit():
364+
nested = {"password": "too-deep-secret"}
365+
for _ in range(12):
366+
nested = {"child": nested}
367+
safe = redact_credentials({"layers": [{"source": nested}]})
368+
assert "too-deep-secret" not in str(safe)
369+
370+
360371
def test_to_html_writes_path(m, tmp_path):
361372
out = tmp_path / "nested" / "map.html"
362373
assert m.to_html(str(out)) is None

tests/project-credentials.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ function credentialProject() {
1414
{ key: "SERVICE_TOKEN", value: "environment-secret", enabled: true },
1515
];
1616
project.preferences.geocoding.apiKeys = { mapbox: "geocoder-secret" };
17+
project.preferences.geocoding.forwardEndpoint =
18+
"https://geocode.example.com/search?key=endpoint-secret";
1719
project.basemapStyleUrl = "https://styles.example.com/map.json?access_token=basemap-secret";
1820
project.layers = [
1921
{
@@ -51,6 +53,7 @@ describe("project credential redaction", () => {
5153
for (const secret of [
5254
"environment-secret",
5355
"geocoder-secret",
56+
"endpoint-secret",
5457
"basemap-secret",
5558
"password",
5659
"url-secret",
@@ -67,7 +70,7 @@ describe("project credential redaction", () => {
6770
assert.deepEqual(project.plugins?.settings, {});
6871
assert.ok(redactedPaths.includes("plugins.settings"));
6972
assert.equal(redactedPaths.includes("basemapStyleUrl"), true);
70-
assert.equal(redactProjectCredentials(original).redactedCount, 8);
73+
assert.equal(redactProjectCredentials(original).redactedCount, 9);
7174
assert.equal(original.plugins?.settings.external.arbitraryName, "plugin-secret");
7275
});
7376

@@ -85,6 +88,27 @@ describe("project credential redaction", () => {
8588
assert.deepEqual(redactCredentials(once), once);
8689
});
8790

91+
it("returns detached inline GeoJSON", () => {
92+
const original = credentialProject();
93+
original.layers[0].source = {
94+
type: "FeatureCollection",
95+
features: [{ type: "Feature", geometry: null, properties: { name: "original" } }],
96+
};
97+
const safe = redactCredentials(original);
98+
const safeSource = safe.layers[0].source as {
99+
features: Array<{ properties: { name: string } }>;
100+
};
101+
safeSource.features[0].properties.name = "changed";
102+
assert.equal(
103+
(
104+
original.layers[0].source as {
105+
features: Array<{ properties: { name: string } }>;
106+
}
107+
).features[0].properties.name,
108+
"original",
109+
);
110+
});
111+
88112
it("fails closed when configuration exceeds the traversal depth", () => {
89113
let nested: Record<string, unknown> = { arbitrary: "too-deep-secret" };
90114
for (let index = 0; index < 12; index += 1) nested = { child: nested };

0 commit comments

Comments
 (0)