#1484 flagged that discoverHostPeer() and writeExternalWorkspaceURL()
return URLs sourced from the workspaces table without an isSafeURL
check. Workspace runtimes register their own URLs via /registry/register
— a misbehaving / compromised runtime could register a metadata-IP URL.
Today both functions are gated by Phase 30.6 bearer-required Discover,
so exposure is theoretical. The fix makes them safe regardless of
upstream auth shape.
Changes:
- discoverHostPeer: isSafeURL on resolved URL before responding;
503 + log on rejection.
- writeExternalWorkspaceURL: same guard applied to the post-rewrite
outURL (so a host.docker.internal rewrite is checked AND a
metadata-IP that survived the rewrite untouched is rejected).
- 3 new regression tests:
* RejectsMetadataIPURL on host-peer path (169.254.169.254 → 503)
* AcceptsPublicURL on host-peer path (8.8.8.8 → 200; positive
counterpart so the rejection test can't pass via universal-fail)
* RejectsMetadataIPURL on external-workspace path
setupTestDB already disables SSRF checks via setSSRFCheckForTest,
so the 16+ existing discovery tests remain untouched. Only the new
tests opt in to enabled SSRF.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-quality + efficiency review of PR #2081:
- Drop comma-ok on map type-asserts in filterPeersByQuery —
queryPeerMaps writes name/role unconditionally as string, so the
silent-empty-string fallback was cargo-culted defense that would
HIDE a real upstream shape change in tests rather than surface it.
Plain p["name"].(string) panics on violation, caught by tests.
- Trim filterPeersByQuery doc from 5 lines to 1 — function is 15
lines and self-evident.
- Refactor 6 separate Test functions into one table-driven
TestPeers_QFilter with 6 sub-tests. Net ~80 lines saved + naming
becomes readable subtest names instead of TestPeers_Q_Foo_Bar.
- Set-based peer-id comparison (peerIDSet) replaces fragile
peers[0]["id"] == "ws-alpha" asserts that would silently mask a
future sort/order regression on the production code.
- Fix the broken TestPeers_Q_NoMatches assertion: re-encoding an
unmarshalled []map collapses both null and [] to [], so the
previous json.Marshal(peers) == "[]" check was tautological. Move
the [] vs null distinction to a dedicated test
(TestPeers_Q_NoMatches_RawBodyIsArrayNotNull) that inspects the
recorder body BEFORE unmarshal.
runPeersWithQuery now returns both parsed peers and raw body so the
nil-guard test can use the bytes directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Peers handler at workspace-server/internal/handlers/discovery.go
ignored the ?q= query param entirely — every caller got the full peer
list regardless of what they searched for. The handler exposes peer
identities + URLs, so leaking the unfiltered set on a "filtered"
endpoint is an info-disclosure bug (CWE-862).
Fix: read c.Query("q") and post-filter the in-memory peers slice by
case-insensitive substring match against name OR role. Filtering is
done in Go after the existing 3 SQL reads — keeps the SQL bytes
identical to the no-filter path (no injection vector, no DB-driver
collation surprises) at a small cost. The peer set is bounded by a
single workspace's parent + children + siblings (typically <50
rows), so the in-memory pass is negligible.
Empty / whitespace-only q is a no-op — preserves the no-filter
allocation profile.
Tests (6 new in discovery_test.go):
- TestPeers_NoQ_ReturnsAll — regression baseline (3 peers, no filter)
- TestPeers_Q_FiltersByName — q=alpha → ws-alpha only
- TestPeers_Q_CaseInsensitive — q=ALPHA → ws-alpha (locks in ToLower)
- TestPeers_Q_FiltersByRole — q=design → ws-beta (role-side match)
- TestPeers_Q_NoMatches — empty array, JSON [] not null
- TestPeers_Q_WhitespaceOnly — q=' ' treated as no-filter
Helpers peersFilterFixture + runPeersWithQuery + peerNames keep each
test scoped to the q-behaviour, not re-declaring SQL expectations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(canvas/a11y): aria-hidden SVGs, MissingKeysModal dialog semantics, session cookie auth
Three fixes cherry-picked from issue #1744:
1. aria-hidden on decorative SVG icons:
- DeleteCascadeConfirmDialog.tsx: warning triangle SVG gets aria-hidden="true"
- MissingKeysModal.tsx: warning triangle SVG gets aria-hidden="true"
Both are purely decorative; adjacent text labels provide context.
2. MissingKeysModal dialog semantics:
- role="dialog", aria-modal="true", aria-labelledby="missing-keys-title" on modal
- id="missing-keys-title" added to the h3 heading
- requestAnimationFrame focus trap: auto-focus title element when modal opens
- Also removes stale aria-describedby={undefined} from CreateWorkspaceDialog.tsx
3. Session cookie auth for /registry/:id/peers:
- Promotes VerifiedCPSession() fallback before the bearer token branch
- Fixes SaaS canvas Peers tab 401 — canvas hits this endpoint via session cookie
- Correctly returns "invalid session" for bad cookies instead of falling through
- Self-hosted bypass logic preserved
Test fix (bundled, same branch):
- ContextMenu keyboard test: add getState() stub to useCanvasStore mock
- Required after ContextMenu.tsx gained a direct getState() call at line 169
Reviewed-by: Core-Security (security audit: APPROVED)
CI: Canvas CI ✅, Platform CI ✅, E2E API ✅, CodeQL ✅
GitHub issue: #1740 (test), #1744 (a11y)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(canvas/a11y): aria-hidden SVGs, MissingKeysModal dialog semantics, session cookie auth
Three fixes cherry-picked from issue #1744:
1. aria-hidden on decorative SVG icons:
- DeleteCascadeConfirmDialog.tsx: warning triangle SVG gets aria-hidden="true"
- MissingKeysModal.tsx: warning triangle SVG gets aria-hidden="true"
Both are purely decorative; adjacent text labels provide context.
2. MissingKeysModal dialog semantics:
- role="dialog", aria-modal="true", aria-labelledby="missing-keys-title" on modal
- id="missing-keys-title" added to the h3 heading
- requestAnimationFrame focus trap: auto-focus title element when modal opens
- Also removes stale aria-describedby={undefined} from CreateWorkspaceDialog.tsx
3. Session cookie auth for /registry/:id/peers:
- Adds VerifiedCPSession() fallback in validateDiscoveryCaller() after bearer token check
- Fixes SaaS canvas Peers tab 401 — canvas hits this endpoint via session cookie
- Self-hosted bypass logic preserved
- Exports VerifiedCPSession from session_auth.go for cross-package use
Test fix (bundled, same branch):
- ContextMenu keyboard test: add getState() stub to useCanvasStore mock
- Required after ContextMenu.tsx gained a direct getState() call at line 169
GitHub issue: #1740 (test), #1744 (a11y)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(workspace-server): remove duplicate VerifiedCPSession declaration
The branch accidentally added a second func VerifiedCPSession declaration
that shadows the real implementation, causing go build to fail with:
internal/middleware/session_auth.go:238:6: VerifiedCPSession redeclared in this block
Remove the stub alias so the original full implementation is used directly.
The function already exports correctly for cross-package use via the
VerifiedCPSession() call in discovery.go.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(workspace-server): correct VerifiedCPSession condition in discovery.go
Fix Go build error — 'presented' was declared and not used.
The cookie fallback check was using `if ok, presented := ...; ok` instead
of `if ok, presented := ...; presented`, causing the build to fail in CI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(workspace-server): fix declared and not used 'presented' in discovery.go
Fixes Go build failure:
discovery.go:355:10: declared and not used: presented
discovery.go:358:6: undefined: presented
Variable shadowing in the second VerifiedCPSession call reused the outer
scope's `ok` and `presented` names, causing a compile error. Renamed to
ok2/presented2 to avoid shadowing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Molecule AI Core-FE <core-fe@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Six bugs reported from a live session — all shippable in one commit:
1. Peers tab 401 on local Docker. The /registry/:id/peers endpoint
demands a workspace-scoped bearer token (validateDiscoveryCaller)
which the canvas session doesn't hold. Added the same Tier-1b
dev-mode fail-open hatch that AdminAuth and WorkspaceAuth already
use — gated by MOLECULE_ENV=development + empty ADMIN_TOKEN, so
SaaS production stays strict. Exported IsDevModeFailOpen from the
middleware package for the handler layer to reuse.
2. Org Templates list unscrollable. OrgTemplatesSection was rendered
in the TemplatePalette footer — a div without overflow — so when
it expanded to 15+ entries the list extended past the viewport
with no scroll. Moved it to the top of the flex-1 overflow-y-auto
container. Tall lists now scroll naturally.
3. Chat tab: "My Chat" and "Agent Comms" rendered stacked instead
of switching. HTML `hidden` attribute was being overridden by
Tailwind's `flex` class (display: flex beats the attribute),
so both tabpanels rendered concurrently. Swapped to a conditional
Tailwind `hidden`/`flex` class so the inactive panel is
display:none with proper CSS specificity.
4. Hermes Config form never persists. handleSave wrote config.yaml
but name / tier / runtime / model all live on the workspace row
(or the dedicated /workspaces/:id/model endpoint) — the form
edited in-memory, the request returned 200, the next reload
wiped everything back. Hermes + external runtimes manage their
own config inside the container anyway, so writing config.yaml
is a no-op for them; skip it. Always diff and PATCH the DB-backed
fields that actually changed.
5. Channels "+ Connect" dropdown empty on first open. ChannelsTab's
load() used Promise.all with a silent catch — if EITHER the
channels or adapters fetch failed, both setters were skipped
with no error visible. Switched to Promise.allSettled so each
endpoint settles independently, and the adapters failure now
surfaces via the top-level error state.
6. Plugin registry always "No plugins in registry". Same silent
catch pattern in SkillsTab.tsx — load errors for /plugins,
/plugins/sources, and /workspaces/:id/plugins swallowed without
logging. Replaced the empty catches with console.warn so future
failures are at least visible in devtools.
Tests: 923 passing (unchanged). Go handler tests pass. Server
rebuilt and running with the peers-auth + collapsed-persistence
fixes (pid 15875).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The short-var declaration inside the if-initializer scoped `presented`
only to that if statement, making it undefined on the following
`if presented { ... }` block. Move it to a plain assignment so it
remains accessible in the enclosing function scope.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cherry-pick from #1744 left the backdrop div without aria-hidden="true"
(the outer dialog div got it instead). Re-apply aria-hidden="true" to
the backdrop div so screen readers skip the clickable overlay layer.
Also revert test assertion from bg-black → bg-black/70 to match the
exact class applied to the backdrop div.
1. f675500: aria-hidden="true" on decorative SVG icons in
DeleteCascadeConfirmDialog warning icon and Toolbar stop/restart
/search/help icons. All have adjacent aria-label text or parent
button aria-label — correct.
2. eb87737: session cookie auth fallback for /registry/:id/peers
SaaS canvas path. verifiedCPSession() checked after bearer token
in validateDiscoveryCaller, allowing canvas to hit the Peers tab
via session cookie rather than bearer token. Self-hosted bypass
logic preserved.
3. 80fedd6: MissingKeysModal dialog semantics — role="dialog",
aria-modal="true", aria-labelledby="missing-keys-title",
requestAnimationFrame focus management. Also removes stale
aria-describedby={undefined} from CreateWorkspaceDialog.
Co-authored-by: Molecule AI App & Docs Lead <app-docs-lead@agents.moleculesai.app>
Co-authored-by: molecule-ai[bot] <molecule-ai[bot]@users.noreply.github.com>