Skip to content

Replace canvas share SAS links with an authenticated viewer - #179

Merged
0101 merged 32 commits into
mainfrom
canvas-safe-share
Aug 31, 2026
Merged

Replace canvas share SAS links with an authenticated viewer#179
0101 merged 32 commits into
mainfrom
canvas-safe-share

Conversation

@0101

@0101 0101 commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replace recipient-facing Blob SAS URLs with clean links served by a standalone Entra-authenticated viewer.
  • Keep shared documents in non-public Blob storage, enforce metadata expiry at read time, and isolate active HTML in a sandboxed iframe with CSP and Fetch Metadata gates.
  • Validate share filenames before file I/O, require viewer configuration before network access, raise the configurable expiry ceiling from 7 to 30 days, and add unit, route, and Playwright containment coverage.
  • Consolidate canvas-sharing and remoting CSRF guidance into the current sharing and worktree specifications.

Deployment notes

  • The viewer runs as a separate App Service with managed-identity Blob read access, so recipients receive no storage credential.
  • Access is link-driven for identities accepted by tenant Easy Auth, including B2B guests; there is no per-recipient ACL.
  • Provisioning and the storage lifecycle policy are documented as attended operations. This PR does not include infrastructure-as-code or a replacement lifecycle-policy JSON.

Validation

  • dotnet build treemon.slnx
  • dotnet test src/Tests/Tests.fsproj --filter "Category=Unit&FullyQualifiedName~CanvasShare" - 94 passed

PR CI runs the full non-local suite.

0101 and others added 30 commits August 17, 2026 16:42
Add the CanvasShareViewer ASP.NET Core F# project with exact-name Blob
reads via DefaultAzureCredential, strict path validation, metadata
expiry enforcement, and shell/content GET routes returning a uniform
404. Cover the pipeline with CanvasShareViewerTests.
Render the shell as a sandboxed iframe pointing at the content route and
apply the spec's exact shell/content response policies (CSP, nosniff,
Referrer-Policy, Cache-Control) on every matched route response.
Add viewer route tests plus a self-contained active-HTML fixture covering
routing, headers, indistinguishable not-found outcomes, and byte-identical
document streaming.
Replace user-delegation SAS minting with private-Blob uploads that carry expiresOn metadata, add a validated HTTPS viewerBaseUrl setting, raise the expiry ceiling to 30 days, and return clean /c/<prefix>/<filename> viewer URLs.
Record the user's decisions on the review's six Needs Your Decision findings: a tenant-wide authenticated audience with no enterprise-application assignment, an identical not-found response with no elapsed-time guarantee (malformed paths may fail before storage access), server-independent deployment config writes, and duplicated publisher/viewer contract constants pinned by compatibility tests.
Add a fail-closed audit of the viewer identity's effective Blob-read RBAC.
It enumerates direct, group-derived, and parent-scope-inherited assignments,
resolves each role definition's dataActions/notDataActions, and rejects any
Blob-read grant whose scope is not the share container or a descendant. The
audit runs before any Azure mutation when the identity already exists and
again during deployed-state verification; it reports but never deletes the
offending assignment. Adds mocked regression tests wired into CI and updates
the spec and operator docs.
Limit the viewer's 404-to-not-found path to a genuine BlobNotFound and add an exception boundary before routing that turns non-404 storage and DefaultAzureCredential failures into one fixed, empty 503 with restrictive policy headers in every environment, logging only exception type and Azure status/error code. Pin and assert DOTNET_ENVIRONMENT and ASPNETCORE_ENVIRONMENT to Production in the deployment script, and record the behaviour in the spec and deployment doc.
Look up the expiresOn blob metadata key case-insensitively so a casing variation from Azure Blob metadata no longer makes a live share resolve as malformed/expired. Adds regression coverage over ShareExpiry.isLive and ShareLookup.resolve with a mixed-case key, and records the case-insensitive match in the spec wire contract. Fixes focused-review finding F3.
Mirror one filename predicate on both sides of the share wire contract: non-empty, case-insensitive .html suffix, no path separators, consecutive dots allowed. Validate in WorktreeApi before path/file I/O and again at the CanvasShare.publish boundary, replacing the leafName rewrite so the original casing survives into the Blob name and viewer URL. Adds cross-contract and route tests plus spec updates.
Append frame-ancestors 'none' to the shell route CSP so the viewer shell cannot be framed cross-origin, matching focused-review finding F7. Update the exact-header test expectation and the shell wire-contract row in the spec.
Split the viewer storage boundary so a page load no longer downloads each
shared document twice. BlobReader gains a properties-only exact lookup
backed by GetPropertiesAsync; the shell route resolves against it while the
content route keeps the single body-bearing read. Both routes still
re-validate the path and re-check expiry independently.

Malformed paths now short-circuit to not-found without any storage call,
removing the InvalidPathProbeBlobName probe under the recorded timing
decision the spec already documents.
Reject canvasShare.viewerBaseUrl values whose path is not exactly "/", so
published links can never point at routes the root-mounted viewer does not
serve (focused-review finding F9 / A-07). Spec and tests updated to match.
Serve active canvas HTML only for a fail-closed same-origin iframe
navigation identified by exact single-value Fetch Metadata
(Sec-Fetch-Site: same-origin, Sec-Fetch-Mode: navigate,
Sec-Fetch-Dest: iframe). Direct, top-level, cross-site, partial,
duplicated, and metadata-missing /content requests now receive the
normal non-executable shell after their own independent path and
expiry check, closing the containment escape where a top-level
/content load could self-navigate to an external probe.

Add Playwright containment coverage plus a hostile fixture that
attempts cookie/storage reads, viewer and external fetches, remote
image and form exfiltration, popups, frame self-navigation, and
parent navigation; the external probe receives zero requests.
…ndows

Fix the four failures verification tm-canvas-safe-share-szi found on
PowerShell 7.6 / Azure CLI 2.84: pass the pipe-bearing Linux runtime through
the CLI JSON-file configuration input so az.cmd cannot reinterpret it, read
resource fields that the CLI nests under properties or renames to
appServicePlanId, retry restricted-tenant registration mutations with the one
unambiguous publisher-owned serviceManagementReference, and keep dotnet
publish output out of the package path value.

Add Deployment.Tests.ps1 regressions and run them in CI.
Easy Auth's browser callback requests response_type=code id_token with
response_mode=form_post, but the dedicated app registration disabled
ID-token issuance, so the callback returned 401 for tenant users.

Enable ID-token issuance while keeping browser access-token issuance
disabled, re-read the registration during deployed-state validation to
reject audience, redirect, or issuance drift, and cover the CLI update
shape and both negative cases in the deployment regression tests.
…ection flow

Add CrossSubscriptionCorrection.ps1 with mutually exclusive
-PrepareCrossSubscriptionMove and -ReconcileCrossSubscriptionMove
parameter sets on deploy-canvas-share-viewer.ps1.

Preparation returns before ordinary app discovery, so it no longer
aborts on the global App Service name-availability check while the
source subscription still owns the canonical name. It reconciles only
the destination resource group, replacement identity, and its
container-scoped reader grant, then prints a redacted portal checklist.

Reconciliation requires -ConfirmPortalMoveCompleted before any local
tool or Azure call, resolves the moved app and plan in the approved
destination only, and never checks global name availability.

Ordinary creation and -ValidateOnly keep the unchanged global-name
guard. Deployment.Tests.ps1 covers phase boundaries, the confirmation
gate, subscription scoping, and checklist redaction.
…uth diagnostics

Extract subscription resolution and Azure-context approval into SubscriptionGuard.ps1 dot-sourced after Common.ps1, resolve the selected Azure CLI account before either private subscription lookup so authentication failures keep their real diagnostic, and cover both with deployment regressions.
…ead of appending to it

az webapp identity assign appends rather than replaces, so post-move reconciliation could leave a stale source-subscription identity attached. Removing it via CLI would require naming its source resource ID, which automation may never carry.

Reconciliation now reads a sanitized destination-side summary (identity type, attachment count, prepared-identity match) before any App Service mutation and fails closed with portal guidance on a system-assigned identity or any foreign user-assigned attachment. The portal checklist detaches the attachment before the move, and the spec and operator doc record the detachment window and manual reattach on rollback.
Document the narrow response-text-only exception for redacted reports under gitignored .agents/verify/ written by bd-verify-executor and read by bd-verify-reviewer.
… move

Record the browser-navigation health-probe contract: the post-move Entra
redirect check sends browser navigation headers (Accept: text/html) so the
probe exercises the same path a recipient's browser takes rather than an
API-style client.

Reconciliation itself was an Azure-side operation in the approved personal
subscription only; post-move IDs, etags, timestamps, rollback snapshot, and
PASS evidence stay in the ignored .agents/azure-migration/ manifest.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- remove checked-in Azure deployment reconciliation and consolidate its specs
- src/CanvasShareViewer/BlobStorage.fs:99 - stream content instead of buffering
- src/Server/CanvasShare.fs:143 - make share prefixes a distinct type
- src/Tests/CanvasShareTests.fs:103 - remove redundant sharing tests
- src/Tests/CanvasShareViewerTests.fs:163 - share viewer host lifecycle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings August 28, 2026 09:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The user-assigned identity configuration is incomplete, and navigation probes can mask containment regressions.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 1 Medium severity · 1 Low severity

New issues introduced by this change (3)
Severity Finding
High severity docs/​spec/​canvas-sharing.md — The provisioning contract chooses a user-assigned managed identity, but this exhaustive App Service…
Medium severity src/​Tests/​fixtures/​canvas-share-viewer/​hostile.html — These self-navigation probes run in one JavaScript task, so each later location.replace/_self
Low severity src/​Tests/​CanvasShareViewerTests.fs — This new module is 1,598 lines and combines storage-failure, path, expiry, configuration, and route…
What changed in this PR

Replaces Blob SAS sharing with a private-Blob, Entra-authenticated viewer that enforces expiry and sandboxes active canvas HTML.

Changes:

  • Adds the standalone ASP.NET Core viewer with exact Blob lookup, expiry checks, CSP, and iframe containment.
  • Updates publishing to validate filenames, write expiry metadata, and return clean viewer URLs.
  • Expands tests and consolidates sharing/security documentation.
File Description
treemon.slnx Adds the viewer project.
src/​Tests/​Tests.fsproj Registers viewer tests and reference.
src/​Tests/​fixtures/​canvas-share-viewer/​self-contained.html Adds benign interactive fixture.
src/​Tests/​fixtures/​canvas-share-viewer/​hostile.html Adds containment attack fixture.
src/​Tests/​CanvasShareViewerTests.fs Tests viewer contracts and routes.
src/​Tests/​CanvasShareViewerContainmentTests.fs Adds Playwright containment coverage.
src/​Tests/​CanvasShareViewerContainmentTestHelpers.fs Provides isolated viewer/probe harness.
src/​Tests/​CanvasShareTests.fs Updates publisher and configuration tests.
src/​Tests/​CanvasShareClientTests.fs Uses clean viewer URLs in clipboard tests.
src/​Tests/​CanvasAwarenessTests.fs Updates share-result fixtures.
src/​Shared/​Types.fs Documents the authenticated viewer URL.
src/​Server/​WorktreeApi.fs Validates filenames before file access.
src/​Server/​GlobalConfig.fs Adds viewer URL and 30-day expiry support.
src/​Server/​CanvasShare.fs Replaces SAS signing with metadata and viewer URLs.
src/​Client/​index.html Updates publishing-progress documentation.
src/​Client/​CanvasUpdate.fs Updates viewer-link clipboard terminology.
src/​Client/​AppTypes.fs Updates share-message documentation.
src/​CanvasShareViewer/​ViewerConfiguration.fs Reads required viewer storage settings.
src/​CanvasShareViewer/​ViewerApplication.fs Implements secured shell and content routes.
src/​CanvasShareViewer/​SharePath.fs Validates share URL segments.
src/​CanvasShareViewer/​ShareLookup.fs Performs exact, expiry-gated lookups.
src/​CanvasShareViewer/​ShareExpiry.fs Parses and enforces expiry metadata.
src/​CanvasShareViewer/​Program.fs Starts the viewer with Azure credentials.
src/​CanvasShareViewer/​CanvasShareViewer.fsproj Defines the viewer web project.
src/​CanvasShareViewer/​BlobStorage.fs Implements managed-identity Blob reads.
src/​CanvasShareViewer/​appsettings.json Adds blank deployment placeholders.
scripts/​canvas-share-lifecycle-policy.json Removes obsolete eight-day policy.
docs/​spec/​worktree-monitor.md Consolidates loopback CSRF guidance.
docs/​spec/​session-status-push.md Updates security-spec reference.
docs/​spec/​remoting-csrf-hardening.md Removes consolidated standalone spec.
docs/​spec/​process-execution.md Updates security-spec reference.
docs/​spec/​future/​code-improvements.md Removes completed CSRF roadmap item.
docs/​spec/​canvas-sharing.md Rewrites the sharing architecture and deployment contract.
docs/​spec/​canvas-pane.md Documents authenticated viewer sharing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/spec/canvas-sharing.md Outdated
Comment thread src/Tests/fixtures/canvas-share-viewer/hostile.html Outdated
Comment thread src/Tests/CanvasShareViewerTests.fs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@0101
0101 merged commit e88990a into main Aug 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants