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
22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ RustGIS is released often and fixes go into the next release; older releases don
patches. Check that the problem still happens with the
[latest release](https://github.com/opengeos/RustGIS/releases/latest) or `main`.

## Sharing projects and generated items

Saving removes credentials from supported address fields and recognized executable parameters,
and reports counted removals. Feature data, user notes, foreign-language notebook code and opaque
plugin content can still contain sensitive information. Data-valued command parameters, such as
imported feature text and expressions, are preserved too. Supplied service documents are kept
opaque, with an unchecked-content notice when recognizable credential material is found.
An unchecked-content notice means that content was preserved, not verified safe. Review it
before sharing; saving is not a general secret scanner.

For an older file or log that exposed a key, revoke or rotate it with its service. Saving again
cleans only recognized fields and cannot recall shared copies. For a Jupyter notebook, also clear
saved widget state and save again; printed cell output and user data require your own review.

## Active-login output redaction

While a service login is active, generated output masks its known secrets throughout the
process. A very short password can therefore also mask ordinary words, names or addresses,
including output from another session. This changes displayed output, not the stored data,
requests or permission checks. RustGIS does not retain revoked secrets just to recognize them
in later output; review that output before sharing it.

## What counts as a security problem

RustGIS makes promises about what untrusted plugins, files, agents and web pages can do. A way
Expand Down
18 changes: 13 additions & 5 deletions apps/rustgis-python/python/rustgis/_embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ def _start(
# Notices waiting to be read. The app's messages may arrive on another thread (Jupyter's).
self._held: List[Tuple[str, bool]] = []
self._held_lock = threading.Lock()
self._sent_keys = 0
self._sent_notices: List[str] = []
if sample:
self._session.run("project.sample")
if data is not None:
Expand Down Expand Up @@ -201,7 +203,14 @@ def to_png(
def _push(self) -> None:
"""Sends the session's project to the app, when it changed."""
try:
text = self._session.project_json()
text, keys, notices = self._session._engine._peer_text()
# A new loss may leave the public project text unchanged.
if text != self._project or notices != self._sent_notices:
self._hold([(notice, False) for notice in notices])
self._sent_notices = notices
if keys and (text != self._project or keys > self._sent_keys):
self._hold([(f"{keys} layer address(es) went to the map widget without their keys, tokens, passwords or URL fragments; restore missing values if needed", False)])
self._sent_keys = keys
except RustGISError as e:
self._hold([(f"couldn't show the project in the app: {e}", True)])
return
Expand Down Expand Up @@ -232,16 +241,15 @@ def _from_app(self, content: Any) -> None:
text = content.get("text")
if not isinstance(text, str) or text == self._project:
return
# Opening it reports "Opened ...": not news here, so it is dropped, and what was
# waiting to be read is kept.
# Peer input is an import: keep its containment notices and invalidate old trust.
self._hold(self._session.notices())
try:
self._session.run("project.openText", text=text)
self._session._engine._open_peer_text(text)
except RustGISError as e:
self._hold([(f"the app's project couldn't be read: {e}", True)])
return
finally:
self._session.notices()
self._hold(self._session.notices())
# The app's own text, not the session's re-serialised copy: the app then sees its
# project and doesn't open it again.
self._project = text
Expand Down
36 changes: 34 additions & 2 deletions apps/rustgis-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,41 @@ impl Engine {
}

/// The project as `.rgis` JSON text, as `project.saveAs` writes it (what `project.openText`
/// opens).
/// opens). This save-equivalent output retains a bounded same-process restore snapshot;
/// repeated identical text replaces that snapshot. Unfinished imports are refused.
fn project_json(&self, py: Python<'_>) -> PyResult<String> {
let result = self.with_session(py, |session| serde_json::to_string(&session.project).map_err(|e| e.to_string()));
let result = self.with_session(py, |session| {
let (text, report) = session.project.saved_text(None)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugs (confidence: low-medium). project_json is a read accessor, but it now calls project.saved_text, which goes through durable::output_remembered. Each call with credentials records a restore snapshot in the 32-entry explicit-save cache, so repeated calls can evict the user's real saves. Reopening identical text can then restore credentials from a snapshot the user never saved. It also now fails with "still downloading" while imports are pending, where it used to return text. If this is intended, document it in the docstring; otherwise use the non-remembering output path for this accessor.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aba5b1e: project_json is intentionally save-equivalent and uses the same bounded same-process restore behavior as project.saveAs. Identical text replaces its cache entry rather than consuming another slot on each poll. Its Python docstring and guide now state that behavior and the refusal while imports are incomplete.

Validation: full cargo xtask ci, the locked browser build with RUSTFLAGS=-D warnings, and all-file hooks passed on this commit.

for notice in report.notices() {
session.notify(notice, false);
}
Ok::<_, String>(text)
});
self.flush(py);
result.map_err(RustGISError::new_err)
}

/// Private bridge output: never remembered, and never includes keys in widget state.
fn _peer_text(&self, py: Python<'_>) -> PyResult<(String, u32, Vec<String>)> {
let result = self.with_session(py, |session| {
let (text, report) = session.peer_text(rustgis_engine::Peer::Bridge)?;
let addresses = report.layer_addresses();
let notices = report.notices_without_layer_addresses();
Ok::<_, String>((text, addresses, notices))
});
self.flush(py);
result.map_err(RustGISError::new_err)
}

/// Private bridge ingress bypasses the saved-text cache and creates no User authority.
fn _open_peer_text(&self, py: Python<'_>, text: String) -> PyResult<()> {
let result = self.with_session(py, |session| {
let report = session.open_peer_text(rustgis_engine::Peer::Bridge, &text)?;
for notice in report.notices() {
session.notify(notice, false);
}
Ok::<_, String>(())
});
self.flush(py);
result.map_err(RustGISError::new_err)
}
Expand Down
87 changes: 87 additions & 0 deletions apps/rustgis-python/tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,90 @@ def test_layers_of_a_map_change_the_app():
sites.rename("Places")
assert m._project != before and "Places" in m._project
assert m.layer_names() == ["Places"]


def test_widget_exchange_never_sends_keys_and_checks_the_current_slot():
"""The real receiving method keeps only keys still present on the last pushed slot."""
secret = "RGSYNTH_widget_20261103"
s = rustgis.Session()
s.add_geojson(POINT, name="Sites")
project = json.loads(s.project_json())
project["maps"][0]["layers"][0]["dataset"]["source"] = f"https://h.invalid/a?token={secret}"
s.run("project.openText", text=json.dumps(project))
saved = s.project_json()
assert secret not in saved
other = rustgis.Session()
other.run("project.openText", text=saved)
assert other._engine._peer_text()[1] == 1
m = rustgis.Map(session=s)
assert secret not in m._project
assert any("without their keys" in notice for notice, _ in m.notices())
before = m._project
m.run("info.project")
assert m._project == before
assert m.notices() == []
# A different protected source still has one omitted address and needs a fresh warning.
project = json.loads(m._project)
project["maps"][0]["layers"][0]["dataset"]["source"] = f"https://h.invalid/b?token={secret}"
s.run("project.openText", text=json.dumps(project))
m.run("info.project")
assert any("without their keys" in notice for notice, _ in m.notices())
m.run("info.project")
assert m.notices() == []
incoming = json.loads(m._project)
incoming["maps"][0]["layers"][0]["name"] = "Styled by app"
from_app(m, json.dumps(incoming))
assert m.session._engine._peer_text()[1] == 1
assert not any("lost their keys" in notice for notice, _ in m.notices())
# Direct session changes do not call the widget's push. Its stale record cannot resurrect
# the removed source when the app sends the old slot again.
m.session.run("layer.remove", layer="Styled by app")
incoming["maps"][0]["layers"][0]["name"] = "Stale app slot"
from_app(m, json.dumps(incoming))
assert m.session._engine._peer_text()[1] == 0

for command, params, kind in [
("connection.login", {"apiKey": "demo"}, "executable step(s)"),
("plugin.unknown", {"nested": {"token": "demo"}}, "opaque parameters"),
]:
s = rustgis.Session()
project = json.loads(s.project_json())
clean = {"apiKey": ""} if command == "connection.login" else {"nested": {}}
project["tasks"] = [{"name": "Notice fixture", "steps": [{"title": "Stored only", "command": command, "params": clean}]}]
s.run("project.openText", text=json.dumps(project))
s.notices()
m = rustgis.Map(session=s)
assert m.notices() == [], "clean bridge output needs no loss notice"
before = m._project
project["tasks"][0]["steps"][0]["params"] = params
s.run("project.openText", text=json.dumps(project))
s.notices()
m.run("info.project")
if command == "connection.login":
assert m._project == before, "a newly removed credential need not change public project text"
notices = m.notices()
assert len(notices) == 1 and kind in notices[0][0] and not notices[0][1]
before = m._project
m.run("info.project")
m.run("info.project")
assert m._project == before
assert m.notices() == [], "unchanged internal pushes must not repeat bridge notices"
m.run("layer.template", template="points", name="Changed output")
assert m._project != before
assert m.notices() == notices, "changed outgoing content reports its losses again"
m.run("info.project")
assert m.notices() == []


def test_widget_repeated_table_id_cannot_gain_a_retained_key():
"""The receiving census sees duplicate table ids before project normalization renumbers them."""
s = rustgis.Session()
project = json.loads(s.project_json())
project["tables"] = [{"id":7, "name":"Private table", "geometry_type":"None", "fields":[], "features":[], "source":"https://h.invalid/table?token=RGSYNTH_table_20261103"}]
s.run("project.openText", text=json.dumps(project))
m = rustgis.Map(session=s)
incoming = json.loads(m._project)
incoming["tables"].append(dict(incoming["tables"][0]))
from_app(m, json.dumps(incoming))
assert s._engine._peer_text()[1] == 0
assert any("lost their keys" in notice for notice, _ in m.notices())
6 changes: 6 additions & 0 deletions apps/rustgis-web/src/embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
//! | `{"rustgis": "project", "text": <project JSON>}` | the project changed here (an edit, a pan) |
//! | `{"rustgis": "result", "id", "ok": true, "value"}` or `{…, "ok": false, "error"}` | a `run`'s outcome |
//!
//! Exchanged projects remove recognized credentials from supported configuration fields.
//! Preserved user data, foreign notebook source and opaque parameters may still contain secrets.
//! Incoming projects use peer/import semantics: no identical-save restore, item trust or earlier
//! approvals. Parent origin checks identify the peer; they grant no user authority. The existing
//! `ui.*` command policy is separate from this project-exchange contract.
//!
//! Only messages from the parent window are taken, and the app answers only the origin that sent
//! the first of them. Any page may frame the app, so `run` takes only the interface's commands
//! (`ui.*`: panes, the language, the theme); everything else changes the project, which the page
Expand Down
19 changes: 12 additions & 7 deletions apps/rustgis-web/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ impl Embed {
let queued = std::mem::take(&mut *self.queue.borrow_mut());
for value in queued {
match parse(&value) {
Some(Msg::Project(text)) => match app.run("project.openText", serde_json::json!({"text": text})) {
Some(Msg::Project(text)) => match app.open_peer_text(&text) {
Ok(_) => self.changes.seen(key(&app.session)),
Err(e) => app.session.notify(e, true),
},
Expand All @@ -411,9 +411,14 @@ impl Embed {
}
}
match self.changes.due(key(&app.session), web_time::Instant::now()) {
(true, _) => match serde_json::to_string(&app.session.project) {
Ok(text) => self.answer(&crate::embed::project(&text)),
Err(e) => log::error!("couldn't send the project: {e}"),
(true, _) => match app.session.peer_text(rustgis_engine::Peer::Embedder) {
Ok((text, report)) => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
self.answer(&crate::embed::project(&text));
for notice in report.notices() {
app.session.notify(notice, false);
}
}
Err(e) => app.session.notify(e, true),
},
(false, Some(wait)) => ctx.request_repaint_after(wait),
(false, None) => {}
Expand Down Expand Up @@ -751,7 +756,7 @@ async fn fetch_bytes(url: &str, capture: Capture, omit: &'static [u16]) -> Resul
}
.await;
logins::check(bound).map_err(str::to_string)?;
result.map_err(|error| if login.is_some() { logins::scrub(&format!("{url}: {error}")) } else { error })
result.map_err(|error| if login.is_some() { logins::scrub_error(&format!("{url}: {error}")) } else { error })
}
.await;
diagnostics.record(answer.map_err(|e| Failure::new(url, e))).map_err(|failure| failure.message)
Expand Down Expand Up @@ -815,7 +820,7 @@ async fn post_json(url: &str, body: &str, capture: Capture) -> Result<Vec<u8>, S
}
.await;
logins::check(url).map_err(str::to_string)?;
result.map_err(|error| if login.is_some() { logins::scrub(&format!("{url}: {error}")) } else { error })
result.map_err(|error| if login.is_some() { logins::scrub_error(&format!("{url}: {error}")) } else { error })
}
.await;
diagnostics.record(answer.map_err(|e| Failure::new(url, e))).map_err(|failure| failure.message)
Expand Down Expand Up @@ -884,7 +889,7 @@ async fn head(url: &str, capture: Capture) -> Result<rustgis_ui_egui::rustgis_en
}
.await;
logins::check(bound).map_err(str::to_string)?;
result.map_err(|error| if login.is_some() { logins::scrub(&format!("{url}: {error}")) } else { error })
result.map_err(|error| if login.is_some() { logins::scrub_error(&format!("{url}: {error}")) } else { error })
}
.await;
diagnostics.record(answer.map_err(|e| Failure::new(url, e))).map_err(|failure| failure.message)
Expand Down
15 changes: 15 additions & 0 deletions crates/assistant/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,21 @@ impl Agent {
self.drain(s)
}

/// Peer replacement invalidates pending work and remembered approvals without deleting the
/// conversation. An outstanding provider reply belongs to the previous project generation.
pub fn project_replaced(&mut self) {
self.stop();
self.state = State::Idle;
self.always.clear();
self.last_context.clear();
self.preview = None;
self.retrying_summary = None;
self.child = None;
self.delegations.clear();
self.reports.clear();
self.generation = self.generation.wrapping_add(1);
}

/// Stops the run: a request out is forgotten, calls not yet run are skipped.
pub fn stop(&mut self) {
if !self.busy() {
Expand Down
Loading
Loading