Compare commits

...
Author SHA1 Message Date
centdix 00392ba548 fix 2026-06-23 16:23:41 +02:00
hugocasaandClaude Opus 4.8 ba4b368706 fix: prevent variable push from corrupting is_secret variables (#9705)
* fix: prevent variable push from corrupting is_secret variables

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): unit-test looksLikeWorkspaceCiphertext shape detection

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): scope is_secret downgrade to single-file push, not sync push

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): warn when variable push stores a secret value as already-encrypted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): route workspace-resolution and auth diagnostics to stderr

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cli): rephrase comments to describe current behavior, not history

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:45:36 +02:00
Ruben Fiszelandrubenfiszel 723a65920f chore(main): release 1.737.0 (#9728)
* chore(main): release 1.737.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-06-23 12:10:15 +02:00
c644311eca fix(ext-jwt): reject external JWT auth for non-existent workspaces (#9723)
* fix(ext-jwt): reject external JWT auth for non-existent workspaces

External JWTs are validated (not generated) on our side and never revoked
by us. The usage-tracking upsert into unique_ext_jwt_token ran
unconditionally, so a token carrying a workspace_id whose workspace no
longer exists kept refreshing its row on every presentation — surfacing
as a "new token" in the superadmin external-JWT view.

Gate jwt_ext_auth on the requested workspace existing (EE companion). When
it does not, auth fails (token is unusable) and no usage row is written.
The check is existence-only and intentionally ignores the soft-delete
flag, so deleted-then-restored workspaces keep working.

Bumps ee-repo-ref.txt to the EE companion commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(ext-jwt): cache workspace-existence lookups in jwt_ext_auth

Bumps ee-repo-ref.txt to the EE companion commit that caches the
workspace-existence check added in the previous commit, so a token aimed
at a missing workspace no longer hits the DB on every request (auth
failures aren't cached upstream).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to ac1f6f666f36141cb6ba6f8eaa614821a90464ad

This commit updates the EE repository reference after PR #626 was merged in windmill-ee-private.

Previous ee-repo-ref: e23fa03ec16909c127e8ecf0855595911c29512d

New ee-repo-ref: ac1f6f666f36141cb6ba6f8eaa614821a90464ad

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-06-23 11:25:23 +02:00
Ruben FiszelandClaude Opus 4.8 31d9215e5a fix: bound orphan-cleanup drain rate with capped multi-batch loop (#9730)
Follow-up to #9727. The orphan cleanups (cleanup_job_perms_orphaned and
cleanup_job_result_stream_orphaned_jobs) deleted at most one 100k batch per
monitor iteration. Each statement stays short and lock-light, but a single
batch per ~30s cycle caps the drain rate at ~100k/30s, so a large one-time
backlog (tens of millions of rows) takes ~hours to clear.

Loop the batched delete up to ORPHAN_CLEANUP_MAX_BATCHES (10) times per cycle,
stopping early once a batch deletes fewer than ORPHAN_CLEANUP_BATCH_SIZE rows.
Each DELETE remains bounded (≤100k, short locks, no long single statement),
while per-cycle throughput rises to ~1M rows so backlogs drain ~10x faster.
The per-cycle cap keeps monitor_db responsive.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:24:36 +02:00
Ruben FiszelandClaude Opus 4.8 75bafabeee perf(monitor): hash active-root exclusion in retention delete (WIN-2088) (#9732)
* perf(monitor): hash active-root exclusion in retention delete

The expired-job retention delete (delete_expired_jobs_batch) excluded jobs
belonging to still-active root flows with
`COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)`. That
ScalarArrayOp is evaluated per candidate row as a linear scan of $3, so cost
grows with the number of active root jobs.

Express the exclusion as `NOT IN (SELECT u FROM unnest($3) u WHERE u IS NOT
NULL)` instead. The subquery form lets Postgres build a one-time hashed SubPlan
and apply it as a filter on the ordered index scan, giving O(1) membership per
candidate while preserving the `ORDER BY completed_at ASC LIMIT` early
termination. The `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics
($3 holds non-null PK ids).

Measured on a 2M-row synthetic v2_job_completed (batch LIMIT 20000, 5-run min):

  active roots | != ALL (before) | NOT IN hashed (after)
  -------------|-----------------|----------------------
  100          | 108 ms          | 104 ms
  1000         | 168 ms          | 105 ms
  10000        | 719 ms          | 131 ms

Both forms return identical row sets (verified via EXCEPT, 0 diff). Neutral at
small active-root counts, ~5.5x faster when many flows are active.

Relates to WIN-2088

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(monitor): apply hashed active-root exclusion to log_cleanup mirror

windmill-api-settings/log_cleanup.rs::delete_expired_jobs_batch carries a
byte-identical copy of the retention delete and shared its prepared-query
cache. Updating only monitor.rs removed that shared cache entry and broke the
SQLX_OFFLINE build of the mirror. Apply the same NOT IN (hashed SubPlan)
rewrite so both copies converge on one cached query and the mirror gets the
same speedup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:23:50 +02:00
Ruben FiszelandClaude Opus 4.8 fa3596885b fix: allow SQL args in managed // materialize scripts (#9733)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:13:04 +02:00
Ruben FiszelandClaude Opus 4.8 6d94865109 fix: optimize cleanup_job_perms_orphaned and job_result_stream cleanup queries (#9727)
The job_perms/job_result_stream_v2 orphan cleanups in monitor_db used
`NOT IN` anti-joins, `RETURNING job_id` + `fetch_all` (loading every deleted
UUID into memory) and no batch limit. On high-throughput instances these
tables can accumulate tens of millions of orphaned rows, so a single execution
ran for ~298s; because monitor_db awaits each iteration, the cleanup ran
effectively continuously, saturating DB I/O and starving audit partition
creation.

Rewrite both deletes as bounded `NOT EXISTS` anti-joins selecting `ctid` with
a LIMIT 100000, executed via `.execute()` (using rows_affected instead of
fetch_all). Each run is now fast and bounded, while the 30s monitor cadence is
preserved so the tables keep draining promptly.

Fixes WIN-2088

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:14:40 +02:00
Ruben FiszelandClaude Opus 4.8 8dea38383f fix: prevent silent audit-partition outage via monitor watchdog + alert (#9729)
The monitor loop runs ~25 periodic tasks under a single join!, so any one
stuck on a non-DB await (statement_timeout only bounds DB statements) freezes
the whole loop indefinitely — silently halting audit-partition creation. Once
the missing partition's date is reached, audit inserts fail; because login
writes its audit row in the same transaction, that poisons the login tx and
locks every user out.

- Wrap monitor_db in a 600s timeout (> statement_timeout) so a stuck task can
  no longer freeze the loop; report a critical error and continue on timeout.
- After creating partitions, verify the lookahead window is actually covered
  and raise a critical alert naming any missing partitions, turning a silent
  latent outage into an early page.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:13:29 +02:00
2879cbb65a feat(apps): opt-in sandbox isolation for published & raw apps (alpha) (#9420)
* feat(apps): sandbox published & raw apps with a scoped embed token

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: point ee-repo-ref at embed-token EE commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): allow top-navigation from the sandboxed app iframe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): share app localStorage across apps via the embedder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): publisher disable-sandbox option with per-version viewer consent

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sqlx): cache for disable-sandbox queries

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump ee-repo-ref to disable-sandbox EE commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): always sandbox the served raw-app wrapper + viewer fixes

The raw-app wrapper served by get_raw_app_data now always carries
`CSP: sandbox`. The publisher "disable sandbox isolation" opt-out is applied
entirely on the viewer side, which (after per-version consent) builds its own
same-origin blob wrapper — so the backend-served document stays isolated
regardless of how it is reached, never via a relaxed real-origin URL.

Also:
- CORS on the global /apps_u mount so the opaque viewer can load custom-path
  public apps cross-origin.
- Reject runnable-bridge messages unconditionally until the iframe is bound.
- Relay the viewer's in-app hash up to the embedder address bar so deep links
  stay shareable (hash only; embedder keeps its own pathname).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): render public raw apps single-iframe (drop embed token)

Public raw apps now render directly on the real origin with a single
opaque bundle iframe and the page credential, instead of the opaque
viewer + scoped-token indirection. The author bundle stays isolated in
its own opaque iframe (CSP-sandboxed); low-code apps, whose code runs in
the viewer frame, keep the opaque viewer + scoped token.

embed_token now reports raw_app and skips minting a token for raw apps;
the access check still gates visibility.

Also set disable_sandbox: None in the remaining Policy constructors so
the full feature build (all_sqlx_features, enterprise, license) compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump ee-repo-ref to single-iframe raw-app EE commit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): grandfather existing apps as legacy-unsandboxed + authed-only consent

Existing apps are stamped by migration as `legacy_unsandboxed` so they keep
running same-origin on upgrade — no breakage and no consent prompt. New apps are
sandboxed by default; re-deploying an app clears the flag.

The publisher `disable_sandbox` consent prompt is now shown only to authenticated
viewers — an anonymous viewer has no session to expose, so the prompt was
meaningless friction.

embed_token reports `legacy_unsandboxed` and `authed`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump ee-repo-ref to legacy-unsandboxed EE commit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): deploy-time migration prompt for legacy-unsandboxed apps

On the first re-deploy of a grandfathered (legacy-unsandboxed) app, the
publisher must explicitly choose: enable sandbox isolation (the flag is
cleared → the app becomes sandboxed) or keep running without isolation
(→ disable_sandbox, with per-version viewer consent). updatePolicy() no
longer carries the legacy flag through a deploy, so the choice is what
sticks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(apps): disable the sandbox-isolation toggle until the app is deployed

The Deploy-drawer "Disable sandbox isolation" toggle called setPublishState()
— which updates the app by path — even before the app was first deployed, when
the path is empty, throwing an error. Guard it with disabled={!savedApp},
matching the adjacent visibility toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(apps): sandbox the in-workspace low-code app viewer in an opaque iframe

Extend the opaque-origin iframe isolation to the logged-in /apps/get viewer.
/apps/get becomes an embedder that keeps the workspace chrome + Edit button and
renders the app inside a cookieless, chrome-less /app_embed viewer route, handed
a scoped embed token minted from the member's session. The app frame runs in an
opaque origin (no allow-same-origin), so it cannot reach the member's session
cookie or window.parent.

- apps.rs: get_app_embed_token_for_path (authed, by-path, scope + RLS gated);
  mint_app_embed_token grants a path-scoped apps:read:{path} so the viewer can
  load its own app definition and no other
- lib.rs: CORS on /apps (bearer-token only, no cookies) for the opaque viewer's
  by-path reads
- new /app_embed/[workspace]/[...path] viewer route (private analog of /public)
- PublicAppFrame: viewerUrl prop to point the opaque iframe at the viewer route

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): unify in-workspace app viewers on the shared sandboxed path

Route every in-workspace app display (low-code and raw) through the same
PublicAppFrame -> PublicApp machinery as the public viewer, so the sandbox /
legacy-unsandboxed / disable-sandbox-consent behavior is identical on every page.

- new InWorkspaceAppViewer renders both app types via PublicAppFrame; /apps/get
  and /apps_raw/get become thin wrappers over it
- /apps_raw/get previously rendered RawAppPreview directly (always isolated, with
  no legacy-grandfathering or consent handling); now consistent with the rest
- retire the legacy same-origin raw viewer /apps/get_raw/[version] and re-point the
  apps-list row to /apps_raw/get; remove the dead /apps_raw/[ws]/[version] route
- load the raw bundle secret in the shared viewer (getAppByPath doesn't return it)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): address PR review feedback (scope + policy hardening, nits)

- require handler-level apps:read on list_apps / list_search_apps so a scoped
  embed token cannot read app definitions through the list endpoints. The route
  layer treats apps:run as satisfying read; the handler check (which does not)
  closes the gap.
- treat legacy_unsandboxed as backend-owned: strip any client-provided value in
  create/update so it can only be set by the grandfather migration, not the API.
- document mint_app_embed_token's caller-verifies-access contract.
- use Button's declared onClick prop for the consent action (was onclick, which
  fell into the rest-spread and bypassed the component's click handling).
- test: lock that the embed scopes cannot satisfy domain-level apps:read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(apps): document embed-token endpoints in openapi + fix doc nit

Second-round review nits:
- add the three app embed-token endpoints (apps/embed_token/p/{path},
  apps_u/embed_token/{secret}, and the EE apps_u/embed_token_by_custom_path) plus
  the EmbedTokenResponse schema to openapi.yaml; note .html on get_data
- mint_app_embed_token doc: "Both" -> "All" (it lists three call sites)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): bound embed-token scopes to the caller's own

The embed-token mint now enforces ensure_scopes_within_caller, so the
minted scope set is always within the calling credential's own scopes
(a no-op for regular unscoped sessions). Adds a unit test locking the
boundary and documents the contract on mint_app_embed_token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): raw-app ctx in external embeds + page credential in direct render

- RawAppPreview: engage the storage relay only in opaque frames (probe Web
  Storage instead of just window.parent), so a public raw app embedded in an
  external iframe hydrates ctx/storage directly; add a relay-timeout fallback
  so an unresponsive parent can never stall the ctx handshake.
- PublicAppFrame: in direct render, expose the page's own bearer credential
  through the AuthToken context (JWT public URLs), matching the previous
  route behavior; opaque-viewer mode keeps the embed token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): sandbox isolation UI polish + COI embed support for raw apps

- Deploy drawer: move the sandbox toggle out of "Public URL" into its own
  "Sandbox isolation" section (the setting applies to every viewing surface,
  not just the public URL), with positive phrasing, visible helper text, and
  state-aware alerts (warning when disabled, info for pre-isolation apps).
  Toggling it now toasts its own message instead of the login-mode one.
- Extract the deploy-time migration prompt into a shared
  LegacySandboxMigrationModal built on the common Modal component, and wire
  it into the raw app editor header too (it previously had no prompt, so
  re-deploying a pre-isolation raw app silently changed behavior).
  updateRawAppPolicy now also drops the backend-owned legacy flag, matching
  the low-code updatePolicy.
- Viewer consent prompt: use the common ConfirmationModal and show the app
  path (new appPath prop) instead of the route pathname, falling back to
  "this app" when the path isn't known yet.
- COI embeds: propagate the wm_coep opt-in to the raw-app wrapper document
  and have the backend assert COEP require-corp on it when the flag is
  present — required for the bundle iframe to load when the public app page
  is embedded inside a cross-origin-isolated page. Previously this only
  worked in dev because the Vite proxy injects the header; the production
  response lacked it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): app navigation parity across sandboxed and direct viewers

- Navbar component: same-app items relay query + hash to the embedder page
  (which mirrors them onto the root URL, keeping its own pathname and
  transport params), app items navigate the top page through a validated
  wm_embed_navigate relay instead of the cookieless viewer iframe, and
  external items keep opening a new tab. Selected-item detection now
  recognizes the /app_embed viewer route and ignores transport params.
- Frontend-script `goto` and button `onSuccess: gotoUrl`: same-window
  navigation goes through a shared appNavigateSameWindow helper that relays
  to the embedder inside the opaque viewer (same-origin paths SPA-navigate,
  http(s) URLs do a full load, other schemes rejected) and keeps plain
  window.location everywhere else.
- /apps/get and /apps_raw/get: key the viewer by workspace/path so in-route
  navigation fully remounts it — previously the URL changed but the app (and
  in sandbox mode its path-scoped token) did not follow.
- wm_embed/wm_embedder_origin added to the reserved query params so they no
  longer leak into the app's ctx.query.
- Raw apps: drop the sandbox attribute entirely for the unsandboxed
  (grandfathered/consented) blob path, matching the pre-isolation viewer
  exactly — the attribute added no isolation there and sandboxed popups
  (e.g. OAuth flows).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): preserve grandfathered policy across updates + in-workspace viewer parity

Round of compatibility hardening so pre-existing apps behave exactly as
before on every surface:

- `legacy_unsandboxed` is now preserved across app updates unless the payload
  explicitly clears it (`false`, sent by the editor's migration prompt and the
  sandbox toggle). Unrelated update paths — CLI / git-sync redeploys,
  publish-mode toggles, cross-workspace promotion — no longer silently drop
  the grandfathering. Clients still can never SET the flag.
- The embed-token endpoints (secret, path, EE custom-path) read only the
  sandbox-decision policy fields, leniently, and no longer mint a token for
  raw / legacy / disable_sandbox renders: the token is only consumed by the
  sandboxed low-code render, and minting for the others wrote a useless token
  row per view and could fail the render for scope-restricted callers.
- In-workspace viewer parity with the pre-sandbox `/apps/get`: new
  `inWorkspace` mode on PublicApp (no "Powered by Windmill" badge / user
  overlay, no HTML-result approval gate, column flex wrapper, `hideRefreshBar`
  honored again), and the page's query/hash are forwarded into the opaque
  viewer so `ctx.query` / `ctx.hash` reach the app.
- Raw apps: `window.ctx` is always `{ctx, workspace}` again (anonymous viewers
  of pre-existing bundles rely on `ctx.workspace`), and the runnable bridge's
  job-id scoping now applies only to sandboxed renders (`gateJobIds`) — an
  unsandboxed bundle holds the same credential as the bridge, so gating there
  only broke pre-existing apps polling persisted or runnable-returned job ids.
- Document `disable_sandbox` / `legacy_unsandboxed` in the openapi Policy
  schema; add a unit test for the lenient policy read.
- bump ee-repo-ref to the matching EE commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): keep share-link viewer credentials out of the isolated app context

The JWT path segment of authenticated share URLs is an embedder-side
credential, consumed only to mint the scoped embed token. Two transport
channels still copied it into the isolated frame where app-authored code
runs:

- the opaque viewer iframe src defaulted to window.location.href — the
  public and custom-path routes now pass a sanitized viewerUrl (JWT segment
  stripped, query/hash preserved, captured once so the hash relay does not
  reload the iframe);
- document.referrer on the same-origin iframe navigation carried the full
  embedder URL — both app iframes now set referrerpolicy="no-referrer"
  (sandboxed renders only for the raw bundle iframe, keeping exact legacy
  parity; nothing reads the referrer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): drop unused import inherited from main merge

`slide` import in AssistantMessage.svelte (from #9539) turns `npm run check`
red on this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): redirect the removed raw-app viewer path to the unified viewer

The old same-origin raw-app viewer route (/apps/get_raw/{version}/{path}) was
removed in favor of the sandboxed unified viewer. Re-add a thin client route at
the old path that redirects stale bookmarks to /apps_raw/get/{path}, preserving
query + hash (the pinned version is dropped — the unified viewer shows latest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): narrow embed-token scopes and base consent on browser session

- Embed token: resource access is metadata-only (list/type/exists) via a
  `resources:run` marker — resource values (get/get_value/get_value_interpolated/
  list_search) are no longer reachable. Job reads are by-id only: an `app_embed`
  sentinel blocks the workspace-wide job enumeration/export routes (jobs/list,
  list_filtered_uuids, queue/list, completed/list, queue/export) while by-id
  result polling keeps working.
- disable_sandbox consent now gates on whether the browser holds any Windmill
  session (cookie-only whoami) rather than workspace-scoped auth, so a viewer
  logged into a different workspace is still prompted before a same-origin render.
- db-explorer: resolve the MySQL database name server-side (the metadata query
  already falls back to DATABASE()) instead of reading the resource value
  client-side; getTablesByResource derives the default db from the schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(apps): trim embed-scope and consent comments

Reduce duplication — state the resource/job route exclusions and the
workspace-session-vs-cookie rationale once at their source and reference them
elsewhere; drop contrast/justification phrasing. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): make app sandbox isolation opt-in (alpha)

Replace the disable_sandbox + legacy_unsandboxed policy pair and the
per-version viewer consent with a single positive `sandbox` opt-in flag.
Apps are unsandboxed by default (same-origin, full session — the
pre-isolation behavior), so existing apps are unchanged and no migration
is needed. Publishers opt an app into isolation from the deploy drawer,
flagged alpha.

- Policy.sandbox: Option<bool>; EmbedTokenResponse -> {token, expiration,
  raw_app, sandbox}; mint an embed token only for sandboxed low-code apps.
- Drop the legacy-unsandboxed migration and the deploy-time migration
  prompt; remove the consent modal and the browser-session probe.
- Deploy drawer: a single "Sandbox isolation" toggle (alpha), off by
  default, shared by the low-code and raw editors.
- Bump ee-repo-ref to the companion EE commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): confine embed token to its intended user/folder/job routes

The embed token's broad read scopes spanned whole domains while the
matching routers are CORS-enabled for the opaque app iframe:

- users:read / folders:read were domain-wide, so the token could reach
  users/list, users/list_usage, users/username_to_email/*, folders/list,
  etc. Restrict to an app_embed-sentinel allowlist: only users/whoami and
  folders/listnames; deny the rest of those domains.
- jobs:read allowed jobs/completed/export, missed by the job denylist.
  Add it alongside jobs/queue/export.

Extend the embed-scope allow/deny test matrix to cover all of these.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(apps): align sandbox comments with the opt-in model

The consent prompt, deploy-time migration, and legacy-unsandboxed
grandfathering were removed when sandbox isolation became an opt-in
policy flag; update the comments that still described them so they
match the two-state (default-unsandboxed / opt-in-sandboxed) reality.
Comments only, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): confine embed-token job reads to runs the app launched

App component jobs are stamped `created_by = the viewer`, so an embed token
reads its own runs via the launched-by-viewer fast path. The token then also
inherited the viewer's broader job access (share links, folder ACLs, admin
RLS), letting user-authored app JS reuse it to read unrelated jobs by id. Stop
embed tokens at the fast path: only jobs the viewer launched, never those
merely visible to them. Return NotFound so the untrusted app can't probe
existence.

Regression test: an embed token reads its own launched job but is denied the
foreign job (result/logs/getupdate) an admin viewer's normal token can read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): allowlist embed-token apps/jobs routes + scope run to the app

The embed token's apps:run/jobs:read reached more than a running app needs.
Replace the job denylist with strict per-domain allowlists on the app_embed
sentinel:

- Apps: only the app's own definition (apps/get/p/<path>) and the public
  app-serving endpoints (apps_u/*). Denies workspace app inventory
  (exists, custom_path_exists, list, list_paths*).
- Jobs: only the by-id poll routes the frontend JobLoader uses. Denies job
  counts and the job_signature/resume_urls capability-minting routes (the
  by-id reads remain confined to the app's own runs).

Drop unqualified apps:run from APP_EMBED_SCOPES; mint apps:run:<path> instead
and authorize apps:run:<requested path> first in execute_component, so the
token can only run its own app's components, not another app's.

Extend the embed-scope route matrix and add a path-scoped run unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(apps): clarify the sandbox toggle vs the on-behalf-of model

The deploy-drawer sandbox copy leaned on "session" in a way that collided
with the on-behalf-of permissioning right above it. Reword it to say the
toggle governs what the app's browser-side code can reach in the viewer's
browser — distinct from who its runnables execute as — and rename the label
to "Isolate the app from the viewer's browser session".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): path-scope embed-token S3 download to its own app

The apps_u/* allowlist also admitted apps_u/download_s3_file/<path>, whose
handler authorized any authenticated caller — so an embed token minted for app
A could download app B's S3 files via B's on-behalf policy. Add the same
path-scoped guard execute_component uses: download_s3_file_from_app now checks
apps:read:<path> first, confining the token to its own app. Other path-taking
apps_u routes are already covered (writes lack apps:write; embed_token/p
path-checks; public_resource is type-constrained).

Extend the path-scoping unit test to cover apps:read (download) alongside
apps:run (execute).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): path-scope public-app-by-secret read to the embed token's app

The apps_u/* allowlist admitted apps_u/public_app/<secret>, whose handler only
checked the viewer's read access — so an embed token minted for app A could read
app B's definition by secret (confused deputy via the viewer's identity).
get_public_app_by_secret now binds a scoped caller to the resolved app with
check_scopes(apps:read:<path>), confining it to its own app; unscoped sessions
and anonymous access are unchanged.

get_raw_app_data needs no binding (pure secret capability, no caller identity).
Document the full set of app-resolving handlers the path-scoped read covers.

Bump ee-repo-ref for the companion custom-path fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): preserve pre-sandbox behavior for db-explorer, edit link, jwt

Three behavior-parity fixes for non-sandboxed (existing) apps that the
sandbox-isolation refactor changed incidentally:

- DB-explorer MySQL table picker: when the connection can see multiple
  non-system schemas, label the default db's tables unprefixed again. The
  resource-value read was removed globally, so identify the default db from
  the introspection script's `DATABASE() AS default_db_name` (carried on
  SQLSchema.defaultDb) instead of guessing "the single schema key". Equivalent
  to the prior resource.database match; editor-only (table picker).
- In-workspace Edit button: restore `?nodraft=true` on both /apps/get and
  /apps_raw/get, so opening the editor from the viewer loads the deployed
  version, not a draft.
- Custom-path (/a) viewer: restore the "could not authenticate user with jwt
  token" toast when a path JWT fails to resolve a user, instead of silently
  falling through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): confine embed-token S3 downloads to the app's own keys/outputs

download_s3_file_from_app authorized any authenticated caller for any S3 key
(opt_authed.is_some() bypass). A sandboxed app's embed token carries the
viewer's identity, so app-authored JS could fetch arbitrary S3 keys readable by
the on-behalf identity, beyond the app's own declared keys or outputs.

Route app embed tokens through the same allowlist as anonymous viewers — the
app's declared allowed_s3_keys, or files produced by this app's own component
runs — instead of the authed bypass. The produced-files check is parameterized
by created_by (the embed viewer for a token, else anonymous) so a sandboxed
app's own S3 outputs still render while arbitrary keys are denied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): let embed tokens cancel their own jobs; gate cancel to launcher

A sandboxed low-code app supersedes an in-flight component run on re-run by
canceling it, but the embed token only had jobs:read, so cancellation silently
failed and prior jobs ran to completion.

- Permit the by-id jobs_u/queue/cancel POST for app_embed tokens at the route
  layer (the only write reachable through the existing by-id allowlist).
- Gate cancel_job_api: an app_embed token may cancel ONLY jobs it launched
  (created_by == viewer). cancel_job_api had no other per-job ownership check,
  so this also confines the token instead of letting it cancel any job by id.
- /app_embed now sets workspaceStore so cancellation targets the right
  workspace instead of an empty/stale one in the cookieless iframe.

Add a shared has_app_embed_sentinel helper; cover cancel in the route matrix
and the jobs_read_auth integration test (own job cancelable, foreign denied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): drop get_root_job_id from the embed-token job allowlist

Audit of the embed token's reachable job routes: get_root_job (jobs_u/
get_root_job_id) has no access check in its handler at all — it returns any
job's root-job id by id — and the app runtime never calls it. Remove it from
the by-id allowlist so the embed token can't probe a foreign job's flow lineage;
add a denied-route assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(apps): scope sandboxed-app localStorage per app

Sandboxed apps shared one localStorage store (one key on the real origin), so an
app could read or clobber another app's keys — and, with job ids stashed there,
reuse its embed token to read another app's job. Scope the backing store per app.

The embed-token endpoints now return the resolved app_path (EmbedTokenResponse;
not a new disclosure — the viewer already receives the path when it loads the
app). PublicAppFrame (low-code) and RawAppPreview (raw) key their backing store
by it: wm_apps_localstorage:<app_path>. Same app shares one store across its
public and in-workspace surfaces; different apps are isolated. Unsandboxed apps
are unaffected (real same-origin localStorage, as before).

Bump ee-repo-ref for the companion custom-path change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): scope embed access checks to embed tokens + key app storage by workspace

- Apply the path-scoped read/run checks on the public-by-secret read and the
  component run path only when the caller is an app embed token, so other
  caller types keep their prior access.
- Key the sandboxed app's backing client storage by workspace + path instead
  of path alone, and return the resolved workspace from the embed-token
  endpoints so the custom-path viewer can derive it.
- Show a clear message instead of an indefinite loader when the viewer route
  is opened outside its embedder.

Bumps ee-repo-ref to 5b8476b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(apps): mint embed tokens only from the trusted embedder caller

An app embed token must not reach the embed-token mint endpoints; refresh
minting stays with the embedder session/JWT. Enforced at the scope route
layer and at the mint chokepoint, with a route-matrix regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(apps): support S3 upload and frontend-script S3 download in sandboxed apps

Sandboxed apps run with a scoped embed token (no cookie). Let the app's
S3 file-input upload and the frontend-script download({s3}) helper work in
that context: upload is reachable with apps:run and re-checked per-app at the
handler; the script download routes through the app-scoped apps_u endpoint
with the embed token instead of the cookie-authed job_helpers path. Default
(unsandboxed) apps are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: update ee-repo-ref to b0cb761bf9852974e571b2978032d310cc998517

This commit updates the EE repository reference after PR #600 was merged in windmill-ee-private.

Previous ee-repo-ref: e673c714a4618fdb72353a475f49c748e6016642

New ee-repo-ref: b0cb761bf9852974e571b2978032d310cc998517

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-23 10:05:00 +02:00
81 changed files with 3504 additions and 768 deletions
+21
View File
@@ -1,5 +1,26 @@
# Changelog
## [1.737.0](https://github.com/windmill-labs/windmill/compare/v1.736.0...v1.737.0) (2026-06-23)
### Features
* **apps:** opt-in sandbox isolation for published & raw apps (alpha) ([#9420](https://github.com/windmill-labs/windmill/issues/9420)) ([2879cbb](https://github.com/windmill-labs/windmill/commit/2879cbb65a4122c86b4a472206d74d9009b07904))
### Bug Fixes
* allow SQL args in managed // materialize scripts ([#9733](https://github.com/windmill-labs/windmill/issues/9733)) ([fa35968](https://github.com/windmill-labs/windmill/commit/fa3596885bf2d7ee8859f295e820ee362c756911))
* bound orphan-cleanup drain rate with capped multi-batch loop ([#9730](https://github.com/windmill-labs/windmill/issues/9730)) ([31d9215](https://github.com/windmill-labs/windmill/commit/31d9215e5a61f19662cc87be8147007e1d47ebb6))
* **ext-jwt:** reject external JWT auth for non-existent workspaces ([#9723](https://github.com/windmill-labs/windmill/issues/9723)) ([c644311](https://github.com/windmill-labs/windmill/commit/c644311eca4bcaf4b68058cf1d5d79d4078aee1a))
* optimize cleanup_job_perms_orphaned and job_result_stream cleanup queries ([#9727](https://github.com/windmill-labs/windmill/issues/9727)) ([6d94865](https://github.com/windmill-labs/windmill/commit/6d9486510933af9109f52011d93b13847dbdbb39))
* prevent silent audit-partition outage via monitor watchdog + alert ([#9729](https://github.com/windmill-labs/windmill/issues/9729)) ([8dea383](https://github.com/windmill-labs/windmill/commit/8dea38383f884f59b2956c39f1424005a21265bd))
### Performance Improvements
* **monitor:** hash active-root exclusion in retention delete (WIN-2088) ([#9732](https://github.com/windmill-labs/windmill/issues/9732)) ([75bafab](https://github.com/windmill-labs/windmill/commit/75bafabeeec76cf6da33eef41f588e37071df011))
## [1.736.0](https://github.com/windmill-labs/windmill/compare/v1.735.0...v1.736.0) (2026-06-23)
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_perms\n WHERE ctid IN (\n SELECT jp.ctid FROM job_perms jp\n WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jp.job_id)\n LIMIT 100000\n )",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "25ecae25ebc03d6296b0e72482a9201c2bffb0f0f7419b0b598b2786bdb326ab"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_result_stream_v2\n WHERE job_id NOT IN (SELECT id FROM v2_job_queue)\n AND job_id NOT IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at > NOW() - INTERVAL '60 seconds'\n )\n RETURNING job_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "454a611a5a162b2ace137c139bd5383bc7fe142c515dac3edd47991839485e51"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_perms\nWHERE job_id NOT IN (SELECT id FROM v2_job_queue)\nRETURNING job_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "c825fa5c6e287068aeaad994c0b42b8ad59b9129f032c6b918c27426ab304f2b"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_result_stream_v2\n WHERE ctid IN (\n SELECT jrs.ctid FROM job_result_stream_v2 jrs\n WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jrs.job_id)\n AND NOT EXISTS (\n SELECT 1 FROM v2_job_completed c\n WHERE c.id = jrs.job_id\n AND c.completed_at > NOW() - INTERVAL '60 seconds'\n )\n LIMIT 100000\n )",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "d059b8a3771e4ac4cd07990ecca84daaed616dc1ac609a6ca81bcd446e4dc230"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app\n FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "policy",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "version",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "raw_app",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null,
null,
false
]
},
"hash": "e0a40d2aba02bd6c502d746471c9c14db8fcaaaf8e3c44fb5ea4ed763a1849dd"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.path, a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app\n FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.id = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "policy",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "version",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "raw_app",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false,
null,
null,
false
]
},
"hash": "ef9caf3da759ee6059922632cdf1fdf5c006554a70a322ca8d3449cdba7840db"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $2\n AND (j.kind = 'appscript' OR j.kind = 'preview')\n AND j.created_by = 'anonymous'\n AND c.started_at > now() - interval '3 hours'\n AND j.runnable_path LIKE $3 || '/%'\n AND c.result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n )",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $2\n AND (j.kind = 'appscript' OR j.kind = 'preview')\n AND j.created_by = $4\n AND c.started_at > now() - interval '3 hours'\n AND j.runnable_path LIKE $3 || '/%'\n AND c.result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n )",
"describe": {
"columns": [
{
@@ -11,6 +11,7 @@
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
@@ -20,5 +21,5 @@
null
]
},
"hash": "a72e7fb55d7268fe1ea40015a4a84b12dd961ba732772d8f6c59d18fe05f285d"
"hash": "f2760b688a907e7679106aaa2c2063385785f7c2e97364bc04392df11cf364d7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id",
"describe": {
"columns": [
{
@@ -20,5 +20,5 @@
false
]
},
"hash": "45997fcb4d9d62c7f7011966bf59bdb86e12ce1d0c8e925e738d2645121a5c1f"
"hash": "fbe3a876efd1253d2ef086b03366b2bd117ceb6bc152d2abcd45850ff6aecff9"
}
+80 -78
View File
@@ -13735,7 +13735,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13817,7 +13817,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13850,7 +13850,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13863,7 +13863,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"argon2",
@@ -14001,7 +14001,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14024,7 +14024,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14037,7 +14037,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14063,7 +14063,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14073,7 +14073,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14090,7 +14090,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14112,7 +14112,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14135,7 +14135,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14151,7 +14151,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14172,7 +14172,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14193,7 +14193,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14207,7 +14207,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14242,7 +14242,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14267,7 +14267,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14285,7 +14285,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14307,7 +14307,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14327,7 +14327,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14364,7 +14364,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14392,7 +14392,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"lazy_static",
"serde",
@@ -14404,7 +14404,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14429,7 +14429,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14443,7 +14443,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14476,7 +14476,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14490,7 +14490,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14509,7 +14509,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14611,7 +14611,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14630,7 +14630,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"regex",
"serde",
@@ -14645,7 +14645,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14669,7 +14669,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"futures",
@@ -14686,7 +14686,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14702,7 +14702,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14723,7 +14723,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14754,7 +14754,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14779,7 +14779,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14813,7 +14813,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"futures",
@@ -14831,7 +14831,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14840,7 +14840,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14852,7 +14852,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14864,7 +14864,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14876,7 +14876,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14888,7 +14888,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14900,7 +14900,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14911,7 +14911,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14922,7 +14922,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14934,7 +14934,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14945,7 +14945,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14979,7 +14979,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14993,7 +14993,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15010,7 +15010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15023,7 +15023,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -15035,7 +15035,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15053,7 +15053,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15069,7 +15069,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15085,7 +15085,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -15096,7 +15096,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"const_format",
@@ -15173,7 +15173,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15184,17 +15184,19 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
"axum 0.8.9",
"base64 0.22.1",
"chrono",
"futures",
"hex",
"http 1.4.2",
"hyper 1.10.1",
"lazy_static",
"magic-crypt",
"quick_cache",
"reqwest 0.13.1",
"serde",
@@ -15216,7 +15218,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15240,7 +15242,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15273,7 +15275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15306,7 +15308,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15326,7 +15328,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15360,7 +15362,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15396,7 +15398,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15419,7 +15421,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15443,7 +15445,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15467,7 +15469,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15502,7 +15504,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15530,7 +15532,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15555,7 +15557,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"bitflags 2.13.0",
@@ -15574,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15684,7 +15686,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.736.0"
version = "1.737.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.736.0"
version = "1.737.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
de49fda2320504ad9e7d2d31c7033d71dbf6ca43
ac1f6f666f36141cb6ba6f8eaa614821a90464ad
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.736.0"
version = "1.737.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.736.0"
version = "1.737.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+38 -12
View File
@@ -1446,19 +1446,45 @@ Windmill Community Edition {GIT_VERSION}
} else {
None
};
monitor_db(
&conn,
&base_internal_url,
server_mode,
worker_mode,
false,
tx.clone(),
Some(MonitorIteration {
rd_shift,
iter: monitor_iteration,
}),
// Hard cap on a single monitor pass. monitor_db runs all its
// periodic tasks under one join!, so a single task stuck on a
// non-DB await (statement_timeout only bounds DB statements)
// would otherwise freeze the whole loop indefinitely — silently
// stopping critical maintenance like audit-partition creation.
// Larger than statement_timeout (5min) so a slow-but-progressing
// statement is never killed prematurely.
const MONITOR_DB_TIMEOUT: Duration = Duration::from_secs(600);
let monitor_timed_out = tokio::time::timeout(
MONITOR_DB_TIMEOUT,
monitor_db(
&conn,
&base_internal_url,
server_mode,
worker_mode,
false,
tx.clone(),
Some(MonitorIteration {
rd_shift,
iter: monitor_iteration,
}),
),
)
.await;
.await
.is_err();
if monitor_timed_out {
windmill_common::utils::report_critical_error(
format!(
"monitor task did not finish within {}s and was aborted; \
a background maintenance task is likely stuck. \
Continuing to the next iteration.",
MONITOR_DB_TIMEOUT.as_secs()
),
db.clone(),
None,
None,
)
.await;
}
monitor_iteration += 1;
if let Some(handle) = warn_handle {
handle.abort();
+124 -51
View File
@@ -1532,14 +1532,22 @@ async fn delete_expired_jobs_batch(
.await?;
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
// ORDER BY completed_at ensures we delete oldest jobs first
// ORDER BY completed_at ensures we delete oldest jobs first.
// Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than
// `!= ALL($3)`: the subquery form lets the planner build a one-time hashed
// SubPlan and apply it as a filter on the ordered index scan, giving O(1)
// membership per candidate instead of a per-row linear array scan (which
// degrades sharply when many root jobs are active). The `u IS NOT NULL` guard
// sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids).
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
@@ -4368,39 +4376,65 @@ RETURNING key,job_id
Ok(())
}
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
let result = sqlx::query_scalar!(
"DELETE FROM job_perms
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
RETURNING job_id"
)
.fetch_all(db)
.await?;
// Per-statement cap keeps each delete short and lock-light; the per-cycle batch
// cap bounds total work per monitor iteration so monitor_db stays responsive.
// A large backlog drains across several iterations rather than one long delete.
const ORPHAN_CLEANUP_BATCH_SIZE: u64 = 100_000;
const ORPHAN_CLEANUP_MAX_BATCHES: usize = 10;
if !result.is_empty() {
tracing::info!("Cleaned up {} orphaned job_perms rows", result.len());
async fn cleanup_job_perms_orphaned(db: &DB) -> error::Result<()> {
let mut total: u64 = 0;
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
let count = sqlx::query!(
"DELETE FROM job_perms
WHERE ctid IN (
SELECT jp.ctid FROM job_perms jp
WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jp.job_id)
LIMIT 100000
)"
)
.execute(db)
.await?
.rows_affected();
total += count;
if count < ORPHAN_CLEANUP_BATCH_SIZE {
break;
}
}
if total > 0 {
tracing::info!("Cleaned up {total} orphaned job_perms rows");
}
Ok(())
}
async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
let result = sqlx::query!(
"DELETE FROM job_result_stream_v2
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
AND job_id NOT IN (
SELECT id FROM v2_job_completed
WHERE completed_at > NOW() - INTERVAL '60 seconds'
)
RETURNING job_id",
)
.fetch_all(db)
.await?;
let mut total: u64 = 0;
for _ in 0..ORPHAN_CLEANUP_MAX_BATCHES {
let count = sqlx::query!(
"DELETE FROM job_result_stream_v2
WHERE ctid IN (
SELECT jrs.ctid FROM job_result_stream_v2 jrs
WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = jrs.job_id)
AND NOT EXISTS (
SELECT 1 FROM v2_job_completed c
WHERE c.id = jrs.job_id
AND c.completed_at > NOW() - INTERVAL '60 seconds'
)
LIMIT 100000
)",
)
.execute(db)
.await?
.rows_affected();
total += count;
if count < ORPHAN_CLEANUP_BATCH_SIZE {
break;
}
}
if result.len() > 0 {
tracing::info!(
"Cleaned up {} orphaned job_result_stream_v2 rows",
result.len()
);
if total > 0 {
tracing::info!("Cleaned up {total} orphaned job_result_stream_v2 rows");
}
Ok(())
}
@@ -4436,11 +4470,19 @@ async fn audit_log_retention_days() -> i64 {
}
}
/// Number of days ahead (including today) for which an audit partition must
/// always exist. A missing partition in this window means audit inserts fail
/// once that date is reached — and because some callers (notably login) write
/// the audit row in the same transaction as their own work, that failure
/// poisons the whole transaction, so a missing partition is a hard outage, not
/// just a dropped audit row.
const AUDIT_PARTITION_LOOKAHEAD_DAYS: i64 = 3;
async fn manage_audit_partitions(db: &DB, retention_days: i64) {
let today = chrono::Utc::now().date_naive();
// Create partitions for today and the next 3 days
for days_ahead in 0..=3i64 {
// Create partitions for today and the next few days
for days_ahead in 0..=AUDIT_PARTITION_LOOKAHEAD_DAYS {
let date = today + chrono::Duration::days(days_ahead);
let next_date = date + chrono::Duration::days(1);
let partition_name = format!("audit_{}", date.format("%Y%m%d"));
@@ -4456,9 +4498,6 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) {
}
}
// Drop expired partitions
let cutoff_date = today - chrono::Duration::days(retention_days);
let partitions = sqlx::query_scalar::<_, String>(
"SELECT c.relname::text \
FROM pg_inherits i \
@@ -4468,28 +4507,62 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) {
.fetch_all(db)
.await;
match partitions {
Ok(partitions) => {
for partition_name in partitions {
if let Some(date_str) = partition_name.strip_prefix("audit_") {
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") {
if date < cutoff_date {
let quoted_name =
format!("\"{}\"", partition_name.replace('"', "\"\""));
let sql = format!("DROP TABLE IF EXISTS {quoted_name}");
match sqlx::query(&sql).execute(db).await {
Ok(_) => tracing::info!(
"Dropped expired audit partition {partition_name}"
),
Err(e) => tracing::error!(
"Error dropping audit partition {partition_name}: {e:?}"
),
}
let partitions = match partitions {
Ok(partitions) => partitions,
Err(e) => {
tracing::error!("Error listing audit partitions: {e:?}");
return;
}
};
// Verify the lookahead window is actually covered. If a create above failed
// (or this loop has not run for several days), alert loudly instead of
// letting it surface days later as failed audit inserts and broken logins.
let existing: std::collections::HashSet<&str> = partitions.iter().map(|s| s.as_str()).collect();
let missing: Vec<String> = (0..=AUDIT_PARTITION_LOOKAHEAD_DAYS)
.map(|days_ahead| {
format!(
"audit_{}",
(today + chrono::Duration::days(days_ahead)).format("%Y%m%d")
)
})
.filter(|name| !existing.contains(name.as_str()))
.collect();
if !missing.is_empty() {
report_critical_error(
format!(
"Audit log partitions missing after maintenance run: {}. \
Audit inserts will fail once these dates are reached, which also \
breaks logins (the login audit row shares the login transaction). \
Check for earlier 'Error creating audit partition' logs and verify \
the audit-partition maintenance loop is still running.",
missing.join(", ")
),
db.clone(),
None,
None,
)
.await;
}
// Drop expired partitions
let cutoff_date = today - chrono::Duration::days(retention_days);
for partition_name in &partitions {
if let Some(date_str) = partition_name.strip_prefix("audit_") {
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y%m%d") {
if date < cutoff_date {
let quoted_name = format!("\"{}\"", partition_name.replace('"', "\"\""));
let sql = format!("DROP TABLE IF EXISTS {quoted_name}");
match sqlx::query(&sql).execute(db).await {
Ok(_) => {
tracing::info!("Dropped expired audit partition {partition_name}")
}
Err(e) => tracing::error!(
"Error dropping audit partition {partition_name}: {e:?}"
),
}
}
}
}
Err(e) => tracing::error!("Error listing audit partitions: {e:?}"),
}
}
+39
View File
@@ -19,6 +19,45 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
);
-- App embed token for the admin viewer (test-user). Mirrors a minted sandboxed
-- low-code app token: carries the `app_embed` sentinel plus the embed scope set.
-- Used to assert the token is confined to jobs the viewer LAUNCHED, not every job
-- the (admin) viewer could otherwise read.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
encode(sha256('EMBED_APP_TOKEN'::bytea), 'hex'), 'EMBED_APP_', 'EMBED_APP_TOKEN',
'test@windmill.dev', 'app embed token', false,
ARRAY['apps:run', 'jobs:read', 'app_embed', 'resources:run', 'users:read', 'folders:read']
);
-- A completed app-component job LAUNCHED BY the admin viewer (created_by =
-- test-user), running as the app owner. The embed token must keep reading its own
-- launched job (the `created_by == viewer` fast path).
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner, args
) VALUES (
'12121212-1212-1212-1212-121212121212', 'test-workspace', 'test-user',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/app_component', 'deno', false,
'{"own": "arg"}'
);
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
('12121212-1212-1212-1212-121212121212', 'test-workspace', 1000, 'success'::job_status,
'{"own": "EMBED_OWN_RESULT"}');
-- A QUEUED job launched by the admin embed viewer (created_by = test-user). The
-- embed token may cancel its own launched job; it must NOT cancel another user's.
INSERT INTO public.v2_job (
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, visible_to_owner
) VALUES (
'13131313-1313-1313-1313-131313131313', 'test-workspace', 'test-user',
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
'script', 'deno', 'u/test-user-2/app_component', 'deno', false
);
INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES
('13131313-1313-1313-1313-131313131313', 'test-workspace', '2023-01-01 00:00:00', false, 'deno');
-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check
-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing
-- running-state to a non-reader.
+86
View File
@@ -38,6 +38,10 @@ const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888";
// A queued/running job (no completed row) owned by test-user-2.
const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
// An app-component job launched BY the admin embed viewer (created_by test-user).
const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212";
// A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it.
const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313";
// Secrets that must never leak to an unauthorized viewer.
const RESULT_SECRET: &str = "RESULT_SECRET";
@@ -59,6 +63,19 @@ async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCod
(status, body)
}
async fn post(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) {
let mut req = client()
.post(format!("{base}/{path}"))
.json(&serde_json::json!({}));
if let Some(token) = token {
req = req.header("Authorization", format!("Bearer {token}"));
}
let resp = req.send().await.expect("request");
let status = resp.status();
let body = resp.text().await.expect("body");
(status, body)
}
#[sqlx::test(fixtures("base", "jobs_read_auth"))]
async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -281,6 +298,75 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
"top flow in an unreadable folder must stay denied (got {status}): {body}"
);
// ---- APP EMBED TOKEN: confined to jobs the viewer LAUNCHED, not everything
// the (admin) viewer can otherwise read. The token carries the `app_embed`
// sentinel; an admin's normal token reads VICTIM (asserted above), but the
// embed token must stop at the `created_by == viewer` grant so user-authored
// app JS can't reuse it to read unrelated jobs by UUID.
// Its own launched component job (created_by == viewer) still reads.
let (status, body) = get(
&base,
&format!("completed/get_result/{EMBED_OWN_JOB}"),
Some("EMBED_APP_TOKEN"),
)
.await;
assert!(
status.is_success(),
"embed token must read a job it launched (got {status}): {body}"
);
assert!(
body.contains("EMBED_OWN_RESULT"),
"embed token should get its own launched job result: {body}"
);
// The VICTIM job — created by another user but readable by this admin viewer's
// normal token (asserted above) — is denied to the embed token across result /
// logs / live update. NotFound (not 403) so the untrusted app can't even probe
// existence, and no secret leaks.
for path in [
format!("completed/get_result/{VICTIM}"),
format!("get_logs/{VICTIM}"),
format!("getupdate/{VICTIM}?only_result=true"),
] {
let (status, body) = get(&base, &path, Some("EMBED_APP_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"embed token must not read a job it did not launch ({path}, got {status}): {body}"
);
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
assert!(
!body.contains(secret),
"embed token response for {path} leaked `{secret}`: {body}"
);
}
}
// ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token
// may cancel a job it launched (created_by == viewer), but `cancel_job_api`
// denies (NotFound) a job created by someone else, even though cancel
// otherwise has no per-job ownership check.
let (status, body) = post(
&base,
&format!("queue/cancel/{EMBED_OWN_QUEUED}"),
Some("EMBED_APP_TOKEN"),
)
.await;
assert!(
status.is_success(),
"embed token must cancel a job it launched (got {status}): {body}"
);
let (status, body) = post(
&base,
&format!("queue/cancel/{RUNNING_JOB}"),
Some("EMBED_APP_TOKEN"),
)
.await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"embed token must not cancel another user's job (got {status}): {body}"
);
// ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable
// without a token (public trigger / public app result polling).
let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await;
+119 -1
View File
@@ -451,6 +451,30 @@ pub fn check_route_access(
// Find the domain and kind for this route
let (required_domain, required_kind, route_suffix) = extract_domain_from_route(route_path)?;
// App embed tokens (sentinel) carry broad read scopes (`jobs:read`,
// `users:read`, `folders:read`) that exist only for a handful of routes. The
// whole `/users`, `/folders` and `/jobs` routers are CORS-enabled for the
// opaque app iframe, so default-deny everything in those domains except the
// intended routes — otherwise the token could enumerate/export workspace data.
if has_app_embed_sentinel(Some(token_scopes)) {
if let Some(suffix) = route_suffix.as_deref() {
if app_embed_route_denied(required_domain, suffix) {
return Err(Error::PermissionDenied(
"Access denied. App embed token cannot access this route.".to_string(),
));
}
// The by-id job cancel is a POST (write) that the token's `jobs:read`
// wouldn't satisfy, but cancelling the app's own component runs is
// intended (most components supersede an in-flight run on re-run). Permit
// it here; `cancel_job_api` confines it to jobs the app launched
// (created_by == viewer). A read_only token is still rejected by the
// separate read-only check.
if suffix.starts_with("jobs_u/queue/cancel/") {
return Ok(());
}
}
}
// MCP scopes (mcp:all, mcp:favorites, mcp:hub:*, etc.) use a custom format
// that doesn't fit the standard domain:action model. Verify the token has at
// least one mcp: scope; MCP handlers do their own fine-grained checking.
@@ -537,7 +561,7 @@ const FLOW_JOBS: [&'static str; 6] = [
lazy_static::lazy_static! {
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component"];
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"];
v.extend(SCRIPT_JOBS);
v.extend(FLOW_JOBS);
@@ -640,6 +664,92 @@ const RUN_WHITELISTED_GET_PATHS: [&'static str; 20] = [
"jobs/completed/get_result_maybe/",
];
/// Sentinel scope in app embed tokens. Grants nothing itself; `check_route_access`
/// uses it to deny the workspace-wide job enumeration routes `jobs:read` would
/// otherwise reach, so an embedded app reads only jobs it launched (by id).
pub const APP_EMBED_SENTINEL: &str = "app_embed";
/// True if a token's scopes include the app-embed sentinel (a sandboxed app iframe
/// token). Such tokens carry the viewer's identity but represent untrusted app JS,
/// so several handlers confine them to the app's own resources/runs.
pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
}
/// Routes an app embed token (sentinel) is denied. Its broad scopes (`apps:run`,
/// `jobs:read`, `users:read`, `folders:read`) exist only for a fixed set of routes a
/// running app uses, but the whole `/apps`, `/jobs`, `/users`, `/folders` routers are
/// CORS-enabled for the opaque app iframe. Default-deny those domains via an explicit
/// allowlist so the token can't reach workspace inventory, counts, exports, or
/// capability-minting routes (job signatures / resume URLs).
fn app_embed_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
match domain {
ScopeDomain::Apps => !app_embed_apps_route_allowed(suffix),
ScopeDomain::Jobs => !app_embed_job_route_allowed(suffix),
ScopeDomain::Users => suffix != "users/whoami",
ScopeDomain::Folders => suffix != "folders/listnames",
_ => false,
}
}
/// App routes a running app uses: its own definition (`apps/get/p/<path>`, further
/// path-scoped by `apps:read:<path>`) and the public app-serving endpoints
/// (`apps_u/*`: public_app, public_resource, get_data, and the path-taking
/// `execute_component` / `download_s3_file`, which re-check `apps:run|read:<path>`
/// in their handlers so they stay confined to this app). Everything else in the
/// domain — workspace app inventory (`exists`, `custom_path_exists`, `list`,
/// `list_paths*`, `secret_of`, history, management) — is denied.
fn app_embed_apps_route_allowed(suffix: &str) -> bool {
// The embed-token mint endpoints live under `apps_u/` but they create
// credentials. A running app never calls them — the trusted embedder session/JWT
// mints the token and hands it to the iframe — so deny them here, otherwise an
// app embed token could renew itself indefinitely past the 12h expiry.
if suffix.starts_with("apps_u/embed_token") {
return false;
}
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
}
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
/// denied. By-id reads are further confined to the app's own runs by
/// `require_job_read_access` (the `app_embed` cutoff).
fn app_embed_job_route_allowed(suffix: &str) -> bool {
// `get_root_job_id` is intentionally absent: its handler has no access check at
// all (returns any job's root id by id) and the app never calls it, so denying
// it costs nothing and avoids leaking a foreign job's flow lineage.
const ALLOWED: [&str; 15] = [
"jobs_u/get/",
"jobs_u/getupdate/",
"jobs_u/getupdate_sse/",
"jobs_u/get_logs/",
"jobs_u/get_completed_logs_tail/",
"jobs_u/get_args/",
"jobs_u/get_flow/",
"jobs_u/get_flow_all_logs/",
"jobs_u/get_flow_debug_info/",
"jobs_u/get_log_file/",
"jobs_u/completed/get/",
"jobs_u/completed/get_result/",
"jobs_u/completed/get_result_maybe/",
"jobs_u/completed/get_timing/",
"jobs_u/queue/cancel/",
];
ALLOWED.iter().any(|p| suffix.starts_with(p))
}
/// Resource routes a metadata-only `resources:run` scope (app embed tokens) may
/// GET: pickers (`/list`) and type schemas. Excludes every value-returning route
/// (`get`, `get_value`, `get_value_interpolated`, `list_search`) so resource
/// values — which can hold credentials — are never exposed.
fn resource_metadata_route_allowed(suffix: &str) -> bool {
suffix == "resources/list"
|| suffix.starts_with("resources/list_names/")
|| suffix.starts_with("resources/exists/")
|| suffix.starts_with("resources/type/")
}
fn scope_grants_access(
scope: &ScopeDefinition,
required_domain: ScopeDomain,
@@ -659,6 +769,14 @@ fn scope_grants_access(
let scope_action = ScopeAction::from_str(&scope.action)
.ok_or_else(|| Error::BadRequest(format!("Invalid scope action: {}", scope.action)))?;
// App embed tokens carry `resources:run`: metadata-only resource access via
// default-deny + allowlist (so a new value route is never exposed by accident).
// See `resource_metadata_route_allowed`.
if scope_domain == ScopeDomain::Resources && scope_action == ScopeAction::Run {
return Ok(required_action == ScopeAction::Read
&& route_path.is_some_and(resource_metadata_route_allowed));
}
if !scope_action.includes(&required_action)
&& !(scope_domain == ScopeDomain::Jobs
&& required_action == ScopeAction::Read
+4 -22
View File
@@ -1284,28 +1284,10 @@ async fn create_script_internal<'c>(
if let Err(e) = windmill_parser::sql_materialize::classify_wrap(&ns.content) {
return Err(Error::BadRequest(e.message()));
}
// Managed materialize strips line comments when it wraps the SELECT,
// so a `-- $name (TYPE)` declaration is lost while its `$name`
// reference survives in the embedded SELECT — it would run unbound.
// Managed materialize takes no SQL args (the partition is supplied by
// the engine, not bound). Reject declared args with a clear error.
if let Ok(sig) = windmill_parser_sql::parse_duckdb_sig(&ns.content) {
if !sig.args.is_empty() {
let names = sig
.args
.iter()
.map(|a| format!("${}", a.name))
.collect::<Vec<_>>()
.join(", ");
return Err(Error::BadRequest(format!(
"managed `// materialize` cannot take SQL arguments ({names}): wrapping your \
SELECT drops the `-- $arg` declarations, so they would run unbound. The \
partition is supplied by the engine — reference its value with the \
`{{partition}}` token, or use `// materialize manual` to write the DDL (and \
bind args) yourself."
)));
}
}
// SQL args are supported: managed materialize strips line comments
// (including `-- $name (type)` declarations) when it wraps the SELECT,
// but the executor parses the signature from the un-wrapped script, so
// `$name` references in the SELECT stay bound at run time.
}
// `key=` (merge) and `append` are mutually exclusive reconciliation
// strategies; append (INSERT-only) wins. Surface the conflict rather
@@ -395,13 +395,17 @@ async fn delete_expired_jobs_batch(
.fetch_all(&mut *tx)
.await?;
// Active-root exclusion via NOT IN (hashed SubPlan) instead of `!= ALL($3)`;
// see backend/src/monitor.rs::delete_expired_jobs_batch for the rationale.
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
"DELETE FROM v2_job_completed
WHERE id IN (
SELECT jc.id FROM v2_job_completed jc
LEFT JOIN v2_job j ON j.id = jc.id
WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) != ALL($3)
AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (
SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL
)
ORDER BY jc.completed_at ASC
LIMIT $2
FOR UPDATE OF jc SKIP LOCKED
+102 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.736.0
version: 1.737.0
title: Windmill API
contact:
@@ -7501,6 +7501,22 @@ paths:
workspace_id:
type: string
/apps_u/embed_token_by_custom_path/{custom_path}:
get:
summary: get app embed token by custom path
operationId: getAppEmbedTokenByCustomPath
tags:
- app
parameters:
- $ref: "#/components/parameters/CustomPath"
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/scripts/hub/get/{path}:
get:
summary: get hub script content by path
@@ -10569,6 +10585,10 @@ paths:
- name: secretWithExtension
in: path
required: true
description: >-
App version secret suffixed with the requested file type extension.
Supported extensions are `.js` (JavaScript bundle), `.css`
(stylesheet), and `.html` (sandboxed wrapper document).
schema:
type: string
responses:
@@ -10578,6 +10598,12 @@ paths:
text/javascript:
schema:
type: string
text/css:
schema:
type: string
text/html:
schema:
type: string
/w/{workspace}/apps/list_search:
get:
@@ -10829,6 +10855,23 @@ paths:
- $ref: "#/components/schemas/AppWithLastVersion"
- $ref: "#/components/schemas/UserDraftOverlay"
/w/{workspace}/apps/embed_token/p/{path}:
get:
summary: get app embed token by path
operationId: getAppEmbedTokenByPath
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/w/{workspace}/apps/get/lite/{path}:
get:
summary: get app lite by path
@@ -10946,6 +10989,27 @@ paths:
schema:
$ref: "#/components/schemas/AppWithLastVersion"
/w/{workspace}/apps_u/embed_token/{secret}:
get:
summary: get app embed token by secret
operationId: getAppEmbedTokenBySecret
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: secret
in: path
required: true
schema:
type: string
responses:
"200":
description: embed token
content:
application/json:
schema:
$ref: "#/components/schemas/EmbedTokenResponse"
/w/{workspace}/apps_u/public_resource/{path}:
get:
summary: get public resource
@@ -27833,6 +27897,13 @@ components:
type: string
on_behalf_of_email:
type: string
sandbox:
type: boolean
description: >
Publisher opt-in to app sandbox isolation (alpha). When true the app
is isolated from each viewer's Windmill session. When false/absent
the app runs same-origin with the viewer's full session (the
default, pre-isolation behavior).
ListableApp:
type: object
@@ -28052,6 +28123,36 @@ components:
required:
- version
EmbedTokenResponse:
type: object
properties:
token:
type: string
nullable: true
description: Narrowly-scoped embed token for the iframe. Absent for fully anonymous or raw apps, which load without a scoped token.
expiration:
type: string
format: date-time
nullable: true
description: Expiration of the embed token.
raw_app:
type: boolean
description: Raw apps render single-iframe and skip the opaque-viewer indirection and the embed token entirely.
sandbox:
type: boolean
description: Publisher opted this app into sandbox isolation. When false the viewer runs the app same-origin with its full session.
app_path:
type: string
nullable: true
description: The resolved app path; the embedder uses it to scope the app's backing localStorage per app.
workspace_id:
type: string
nullable: true
description: The resolved workspace; pairs with app_path so apps at the same path in different workspaces don't share a localStorage store.
required:
- raw_app
- sandbox
FlowVersion:
type: object
properties:
+793 -28
View File
@@ -75,6 +75,7 @@ use windmill_common::{
use windmill_object_store::object_store_reexports::{Attribute, Attributes};
use windmill_store::resources::get_resource_value_interpolated_internal;
use windmill_api_auth::{create_token_internal, ensure_scopes_within_caller, NewToken};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
@@ -90,6 +91,7 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
.route("/list", get(list_apps))
.route("/list_search", get(list_search_apps))
.route("/get/p/{*path}", get(get_app))
.route("/embed_token/p/{*path}", get(get_app_embed_token_for_path))
.route("/get/lite/{*path}", get(get_app_lite))
.route("/secret_of/{*path}", get(get_secret_id))
.route(
@@ -134,6 +136,7 @@ pub fn unauthed_service() -> Router {
.route("/delete_s3_file", delete(delete_s3_file_from_app))
.route("/download_s3_file/{*path}", get(download_s3_file_from_app))
.route("/public_app/{secret}", get(get_public_app_by_secret))
.route("/embed_token/{secret}", get(get_app_embed_token))
.route("/public_resource/{*path}", get(get_public_resource))
.route("/get_data/v/{*id}", get(get_raw_app_data))
}
@@ -300,6 +303,13 @@ pub struct Policy {
pub execution_mode: ExecutionMode,
pub s3_inputs: Option<Vec<S3Input>>,
pub allowed_s3_keys: Option<Vec<S3Key>>,
// WIN-2006: publisher opt-in to iframe sandbox isolation (alpha). When true the
// app is isolated from each viewer's Windmill session: low-code renders in an
// opaque-origin iframe with a scoped embed token, raw renders its bundle in an
// opaque iframe. Default/absent means unsandboxed — the app runs same-origin
// with the viewer's full session, the pre-isolation behavior.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<bool>,
}
#[derive(Deserialize)]
@@ -348,6 +358,12 @@ async fn list_search_apps(
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<Vec<SearchApp>> {
// Require domain-level read: this returns every visible app's full value (code).
// The route layer treats `apps:run` as satisfying read, so without this handler
// check a scoped embed token (apps:run + apps:read:<one path>) could read all
// apps' definitions. `check_scopes` uses ScopeDefinition::includes, where run
// does NOT include read, so it correctly denies such tokens.
check_scopes(&authed, || "apps:read".to_string())?;
#[cfg(feature = "enterprise")]
let n = 1000;
@@ -379,6 +395,9 @@ async fn list_apps(
Query(pagination): Query<Pagination>,
Query(lq): Query<ListAppQuery>,
) -> JsonResult<Vec<ListableApp>> {
// Domain-level read (see list_search_apps): keeps a scoped embed token, whose
// `apps:run` only satisfies read at the route layer, from listing all apps.
check_scopes(&authed, || "apps:read".to_string())?;
let (per_page, offset) = paginate(pagination);
let mut sqlb = SqlBuilder::select_from("app")
@@ -553,6 +572,7 @@ async fn list_apps(
async fn get_raw_app_data(
Path((w_id, secret_with_ext)): Path<(String, String)>,
Query(query): Query<std::collections::HashMap<String, String>>,
Extension(db): Extension<DB>,
) -> Result<Response> {
#[cfg(all(feature = "enterprise", feature = "parquet"))]
@@ -575,13 +595,52 @@ async fn get_raw_app_data(
.await?;
let file_type = splitted.next().unwrap_or("");
// Sandboxed wrapper document that hosts the bundle. Served from a real URL
// (not blob:/srcdoc) so we can attach `CSP: sandbox` as a response header,
// which forces an opaque origin even on direct navigation — a raw-app
// bundle can then never reach the authenticated Windmill origin (WIN-2006).
// The `.js`/`.css` are loaded as same-path subresources by this document.
if file_type == "html" {
// ALWAYS served with `CSP: sandbox`, which forces an opaque origin even on
// direct top-level navigation — so this real-origin URL can never be used
// to run a raw-app bundle with the viewer's session (WIN-2006). The
// unsandboxed (default) render is NOT applied here: it is handled entirely
// on the viewer side, which builds its own same-origin wrapper. Relaxing
// this header from a policy flag would let anyone with the share secret
// hand a logged-in victim a same-origin URL that runs the bundle with
// their session — so the standalone document stays sandboxed no matter how
// it is reached.
let html = raw_app_wrapper_html(secret_id);
let mut builder = Response::builder()
.header(http::header::CONTENT_TYPE, "text/html; charset=utf-8")
.header("X-Content-Type-Options", "nosniff")
.header("Cross-Origin-Resource-Policy", "cross-origin")
.header(
http::header::CONTENT_SECURITY_POLICY,
"sandbox allow-scripts allow-forms allow-popups \
allow-popups-to-escape-sandbox allow-downloads allow-modals \
allow-top-navigation",
);
// When the public app page is embedded in a cross-origin-isolated page
// (`wm_coep` opt-in, COEP `require-corp`), this nested wrapper document
// must itself assert COEP to be allowed to load. Opt-in only — COEP
// restricts the bundle's own subresources to CORP'd/same-origin ones
// (e.g. external images would break), so it must not be always-on. The
// viewer propagates the flag from the page URL (see RawAppPreview).
if query.contains_key("wm_coep") {
builder = builder.header("Cross-Origin-Embedder-Policy", "require-corp");
}
return Ok(builder.body(Body::from(html)).unwrap());
}
let file_type = if file_type == "css" {
"css"
} else if file_type == "js" {
"js"
} else {
return Err(Error::BadRequest(
"Invalid file type, only .css and .js are supported".to_string(),
"Invalid file type, only .css, .js and .html are supported".to_string(),
));
};
// tracing::info!("file_type: {}", file_type);
@@ -632,20 +691,128 @@ async fn get_raw_app_data(
if let Some(body) = body {
// let stream = tokio_util::io::ReaderStream::new(file);
let res = Response::builder().header(
http::header::CONTENT_TYPE,
if file_type == "css" {
"text/css"
} else {
"text/javascript"
},
);
let res = Response::builder()
.header(
http::header::CONTENT_TYPE,
if file_type == "css" {
"text/css"
} else {
"text/javascript"
},
)
// nosniff + CORP so the bundle loads correctly as a subresource of
// the opaque, sandboxed wrapper (incl. under a cross-origin-isolated
// / COEP `require-corp` embedder).
.header("X-Content-Type-Options", "nosniff")
.header("Cross-Origin-Resource-Policy", "cross-origin");
Ok(res.body(body).unwrap())
} else {
return Err(Error::NotFound("File not found".to_string()));
}
}
/// HTML wrapper that hosts a raw-app bundle inside a sandboxed, opaque-origin
/// iframe. Served by [`get_raw_app_data`] for the `.html` "file type". It loads
/// the bundle `.js`/`.css` as same-path subresources, shims web storage (which
/// an opaque origin disallows), and waits for the embedder to hand it the user
/// context via `postMessage` before evaluating the bundle — so the bundle never
/// receives a credential and `window.ctx` is set synchronously when it runs.
fn raw_app_wrapper_html(secret: &str) -> String {
const TEMPLATE: &str = r##"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>App</title>
<link rel="stylesheet" href="./__SECRET__.css" />
<script>
(function () {
// Storage shim: an opaque-origin (sandboxed) document has no localStorage and
// accessing it throws. Provide an in-memory implementation so apps that use
// web storage keep working within the session.
try {
window.localStorage.getItem('__wm_probe__');
} catch (e) {
function makeShim(onOp) {
var mem = {};
return {
getItem: function (k) { k = String(k); return Object.prototype.hasOwnProperty.call(mem, k) ? mem[k] : null; },
setItem: function (k, v) { mem[String(k)] = String(v); if (onOp) onOp({ op: 'set', key: String(k), value: String(v) }); },
removeItem: function (k) { delete mem[String(k)]; if (onOp) onOp({ op: 'remove', key: String(k) }); },
clear: function () { for (var k in mem) { delete mem[k]; } if (onOp) onOp({ op: 'clear' }); },
key: function (i) { var ks = Object.keys(mem); return i < ks.length ? ks[i] : null; },
get length() { return Object.keys(mem).length; },
__hydrate: function (obj) { if (obj) { for (var k in obj) { mem[k] = String(obj[k]); } } }
};
}
// localStorage relays each mutation up to the parent (RawAppPreview), which
// backs a single store shared across all apps; sessionStorage stays session-only.
function relayOp(o) { try { window.parent.postMessage({ type: 'wm_ls_op', op: o.op, key: o.key, value: o.value }, '*'); } catch (_) {} }
var ls = makeShim(relayOp);
var ss = makeShim(null);
try { Object.defineProperty(window, 'localStorage', { value: ls, configurable: true }); } catch (_) {}
try { Object.defineProperty(window, 'sessionStorage', { value: ss, configurable: true }); } catch (_) {}
window.__wmStorageShim = { local: ls, session: ss };
// document.cookie also throws in an opaque origin; back it with an in-memory
// jar so reads don't crash apps. This is NOT the real session cookie (which
// is unreachable here) — just an isolated client-side store.
try {
var jar = {};
Object.defineProperty(Document.prototype, 'cookie', {
configurable: true,
get: function () { return Object.keys(jar).map(function (k) { return k + '=' + jar[k]; }).join('; '); },
set: function (v) { var p = String(v).split(';')[0]; var i = p.indexOf('='); if (i > -1) { jar[p.slice(0, i).trim()] = p.slice(i + 1).trim(); } }
});
} catch (_) {}
}
// Keep the iframe hash in sync with the parent so app URLs stay shareable.
function notifyParent() {
try { if (window.parent !== window) { window.parent.postMessage({ type: 'windmill:hashchange', hash: window.location.hash }, '*'); } } catch (_) {}
}
window.addEventListener('hashchange', notifyParent);
var _ps = history.pushState, _rs = history.replaceState;
history.pushState = function () { _ps.apply(this, arguments); notifyParent(); };
history.replaceState = function () { _rs.apply(this, arguments); notifyParent(); };
// ctx handshake: the embedding parent hands us the user context (and any
// persisted storage) before we evaluate the bundle, so `window.ctx` is set
// synchronously when the bundle runs. The bundle <script> is injected only
// after this, never inline, so it always observes a ready context.
var loaded = false;
function loadBundle() {
if (loaded) return; loaded = true;
var s = document.createElement('script');
s.src = './__SECRET__.js';
document.body.appendChild(s);
}
window.addEventListener('message', function (e) {
var d = e.data || {};
if (d.type === 'windmill:ctx') {
window.ctx = d.ctx;
if (window.__wmStorageShim && d.storage) {
window.__wmStorageShim.local.__hydrate(d.storage.local);
window.__wmStorageShim.session.__hydrate(d.storage.session);
}
if (d.initialHash && d.initialHash !== '#' && !window.location.hash) {
try { history.replaceState(null, '', d.initialHash); } catch (_) {}
}
loadBundle();
}
});
try { window.parent.postMessage({ type: 'windmill:ready' }, '*'); } catch (_) {}
// Fallback for contexts that never send ctx (e.g. ctx-less rendering).
setTimeout(loadBundle, 1500);
})();
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
"##;
TEMPLATE.replace("__SECRET__", secret)
}
// async fn get_app_version(
// authed: ApiAuthed,
// Extension(user_db): Extension<UserDB>,
@@ -937,6 +1104,17 @@ async fn get_public_app_by_secret(
let mut app = not_found_if_none(app_o, "App", id.to_string())?;
// Confine the app embed token (the only credential handed to untrusted app JS,
// carrying the viewer's identity + `apps:read:<own path>`) to the app the secret
// resolves to: without this, app JS could reuse the viewer's identity to read any
// app it can see by secret via the RLS check below. Scoped to embed tokens only —
// other callers (anonymous, cookie, plain external JWT) keep their existing access.
if let Some(authed) = opt_authed.as_ref() {
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
check_scopes(authed, || format!("apps:read:{}", app.path))?;
}
}
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
@@ -971,6 +1149,300 @@ async fn get_public_app_by_secret(
Ok(Json(app))
}
/// Scopes granted to a short-lived "app embed token". This is the token the
/// app-embedder page hands the (opaque-origin) app iframe at startup so the app
/// never receives the viewer's session cookie. Instead of restricting which
/// routes a *domain* may hit, we restrict which routes the *token* may hit, so
/// that even a malicious or compromised app document can only reach the
/// endpoints an app legitimately needs. The `app_embed` sentinel turns each of
/// these into a strict route allowlist (`app_embed_route_denied`):
/// - `jobs:read` → by-id job poll/cancel only; enumeration, counts, exports,
/// and `job_signature`/`resume_urls` are denied, and by-id
/// reads are confined to the app's own runs.
/// - `app_embed` → sentinel tagging this as an app embed token (grants nothing).
/// - `resources:run` → resource metadata only (pickers, type schemas), never values.
/// - `users:read` → `users/whoami` only.
/// - `folders:read` → `folders/listnames` only.
/// Plus two path-scoped scopes minted per app (see `mint_app_embed_token`):
/// - `apps:read:<path>` → the app's own definition (`apps/get/p/<path>`); no
/// `apps:write`, so management routes are unreachable.
/// - `apps:run:<path>` → run THIS app's components (`execute_component`, which
/// re-checks the path); `apps_u/*` public-serving routes.
pub const APP_EMBED_SCOPES: [&str; 5] = [
"jobs:read",
windmill_api_auth::scopes::APP_EMBED_SENTINEL,
"resources:run",
"users:read",
"folders:read",
];
/// How long an app embed token stays valid. The embedder re-mints on demand
/// (e.g. after a `401` from the iframe) so this can stay short.
const APP_EMBED_TOKEN_VALIDITY_HOURS: i64 = 12;
#[derive(Serialize)]
pub struct EmbedTokenResponse {
/// Narrowly-scoped token for the iframe. `None` for fully anonymous access
/// (the iframe then calls the public endpoints anonymously).
pub token: Option<String>,
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
/// WIN-2006: raw apps render single-iframe (the bundle is already isolated in
/// its own opaque iframe), so the viewer skips the opaque-viewer indirection
/// and the embed token entirely — it loads the app with the page credential.
#[serde(default)]
pub raw_app: bool,
/// WIN-2006: publisher opted this app into sandbox isolation. When false the
/// viewer runs the app same-origin with its full session (the default,
/// pre-isolation behavior).
#[serde(default)]
pub sandbox: bool,
/// WIN-2006: the resolved app path. The embedder uses it (together with
/// `workspace_id`) to scope the app's backing `localStorage` per app (so
/// sandboxed apps don't share one store). Not a new disclosure — the viewer
/// already receives `path` when it loads the app (e.g. `get_public_app_by_secret`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_path: Option<String>,
/// WIN-2006: the resolved workspace. Pairs with `app_path` for the per-app
/// `localStorage` key so two apps at the same path in different workspaces don't
/// share a store. For custom-path apps the viewer can't derive this itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
}
/// Mint a short-lived, narrowly-scoped embed token for `app_path` when a caller
/// is authenticated. When `opt_authed` is `None` (anonymous access to an
/// anonymous app) no token is minted and the iframe relies on the public
/// endpoints.
///
/// The CALLER MUST verify the viewer's access to `app_path` before calling: this
/// mints a token on behalf of `opt_authed` unconditionally (DB access remains
/// gated by the viewer's own RLS, but the token's existence is not access-checked
/// here). All current call sites (`get_app_embed_token`,
/// `get_app_embed_token_for_path`, and the EE custom-path variant) do this.
///
/// Scope confinement IS enforced here: the minted scopes must be within the
/// caller's own (`ensure_scopes_within_caller`), so a scope-restricted bearer
/// token cannot bootstrap a broader-scoped embed token. For the normal caller —
/// an unscoped browser session — this is a no-op and the mint is purely
/// narrowing.
pub async fn mint_app_embed_token(
db: &DB,
w_id: &str,
app_path: &str,
opt_authed: Option<&ApiAuthed>,
) -> Result<EmbedTokenResponse> {
let token_and_exp = if let Some(authed) = opt_authed {
// An app embed token represents untrusted app JS in the sandboxed iframe; it
// must never reach this mint path to renew itself. The 12h expiry is the
// blast-radius cap on a leaked embed token, and `ensure_scopes_within_caller`
// below would pass a same-scoped renewal (the requested scopes equal the
// caller's own), making the credential indefinitely self-renewable. Refresh
// minting is the trusted embedder session/JWT's job.
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
return Err(Error::NotAuthorized(
"App embed tokens cannot mint or renew embed tokens".to_string(),
));
}
let expiration =
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
// Path-scoped read so the app can fetch its OWN definition (apps/get/p,
// which the in-workspace sandboxed viewer uses) — but no other app's. The
// public viewer fetches via apps_u/public_app and doesn't rely on this.
scopes.push(format!("apps:read:{app_path}"));
// Path-scoped run (NOT unqualified `apps:run`) so the token can only execute
// THIS app's components: `execute_component` re-checks `apps:run:<path>` for
// the requested app, so the token can't drive another app's runnables.
scopes.push(format!("apps:run:{app_path}"));
// A scope-restricted caller token must not bootstrap a broader-scoped
// embed token (`create_token_internal` deliberately does not check this
// itself). No-op for unscoped sessions — the normal embed flow.
ensure_scopes_within_caller(authed, Some(&scopes))?;
let token_config = NewToken::new(
Some(format!("embed_app:{app_path}")),
Some(expiration),
None,
Some(scopes),
Some(w_id.to_string()),
// Never let an embed token gain write capability the caller's own
// session lacks.
Some(authed.read_only),
);
let mut tx = db.begin().await?;
let token = create_token_internal(&mut *tx, db, authed, token_config).await?;
tx.commit().await?;
Some((token, expiration))
} else {
None
};
Ok(EmbedTokenResponse {
token: token_and_exp.as_ref().map(|(t, _)| t.clone()),
expiration: token_and_exp.map(|(_, e)| e),
raw_app: false,
sandbox: false,
app_path: Some(app_path.to_string()),
workspace_id: Some(w_id.to_string()),
})
}
/// Issue an embed token for a public app addressed by its (secret) share id.
/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are
/// reachable without auth, otherwise the caller must be logged in and have read
/// access to the app.
async fn get_app_embed_token(
OptAuthed(opt_authed): OptAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, secret)): Path<(String, String)>,
) -> JsonResult<EmbedTokenResponse> {
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
let app = sqlx::query!(
"SELECT a.path, a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app
FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
WHERE a.id = $1 AND a.workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
let app = not_found_if_none(app, "App", id.to_string())?;
let raw_app = app.raw_app;
let policy_str = app
.policy
.ok_or_else(|| Error::internal_err("App policy missing".to_string()))?;
// Lenient field-level read instead of a strict `Policy` parse: a legacy app
// whose stored policy predates newer required fields must still resolve to
// its (unsandboxed) render here rather than erroring out of the viewer.
let policy = parse_embed_policy(&policy_str)?;
let authed_for_token = if policy.anonymous_execution {
// Anonymous app: still mint a scoped token if the viewer happens to be
// logged in (so the app sees their identity), otherwise stay anonymous.
opt_authed
} else {
let authed = opt_authed.ok_or_else(|| {
Error::NotAuthorized(
"App visibility does not allow public access and you are not logged in".to_string(),
)
})?;
let mut tx = user_db.begin(&authed).await?;
let is_visible = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
id,
&w_id
)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
if !is_visible.unwrap_or(false) {
return Err(Error::NotAuthorized(
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
));
}
Some(authed)
};
// The token is only consumed by the sandboxed low-code render. Raw apps
// render single-iframe with the page credential (WIN-2006 Variant A), and
// unsandboxed apps render same-origin with the viewer's own session — minting
// for those would write a useless token row per view and, worse, could fail
// the whole render for a scope-restricted caller (`ensure_scopes_within_caller`)
// even though no token is needed. The access check above still gates
// visibility in every case.
let mut resp = if raw_app || !policy.sandbox {
EmbedTokenResponse {
token: None,
expiration: None,
raw_app,
sandbox: policy.sandbox,
app_path: None,
workspace_id: None,
}
} else {
mint_app_embed_token(&db, &w_id, &app.path, authed_for_token.as_ref()).await?
};
resp.raw_app = raw_app;
resp.sandbox = policy.sandbox;
resp.app_path = Some(app.path);
resp.workspace_id = Some(w_id.to_string());
Ok(Json(resp))
}
/// Minimal, lenient view of an app policy for the embed-token endpoints
/// (WIN-2006). Reads only the fields the sandbox decision needs, via
/// `serde_json::Value`, so a legacy policy that no longer satisfies the strict
/// [`Policy`] struct (e.g. `triggerables_v2` entries predating now-required
/// fields) still renders instead of failing the viewer with "Not found".
/// A missing/unknown `execution_mode` is treated as NOT anonymous — the
/// strictest access interpretation.
pub struct EmbedPolicyView {
pub anonymous_execution: bool,
pub sandbox: bool,
}
pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
let v: serde_json::Value = serde_json::from_str(policy_str).map_err(to_anyhow)?;
Ok(EmbedPolicyView {
anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"),
sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false),
})
}
/// Authenticated, path-based embed token for the in-workspace app viewer
/// (WIN-2006). Mirrors [`get_app_embed_token`] but keyed by app path and gated by
/// the caller's read access (RLS), so the logged-in `/apps/get` viewer can render
/// the app sandboxed — isolated from the member's full session — using the same
/// scoped token. Raw apps get no token (single-iframe with the page credential).
async fn get_app_embed_token_for_path(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<EmbedTokenResponse> {
let path = path.to_path();
check_scopes(&authed, || format!("apps:read:{}", path))?;
// RLS: the caller must have read access to this app, otherwise it's not found.
let mut tx = user_db.begin(&authed).await?;
let app = sqlx::query!(
"SELECT a.policy::text as policy, a.versions[array_upper(a.versions, 1)] as version, av.raw_app as raw_app
FROM app a JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)]
WHERE a.path = $1 AND a.workspace_id = $2",
path,
&w_id
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let app = not_found_if_none(app, "App", path)?;
let raw_app = app.raw_app;
let policy_str = app
.policy
.ok_or_else(|| Error::internal_err("App policy missing".to_string()))?;
// Lenient parse + mint only for the sandboxed low-code render — see
// [`get_app_embed_token`] for the rationale (identical here).
let policy = parse_embed_policy(&policy_str)?;
let mut resp = if raw_app || !policy.sandbox {
EmbedTokenResponse {
token: None,
expiration: None,
raw_app,
sandbox: policy.sandbox,
app_path: None,
workspace_id: None,
}
} else {
mint_app_embed_token(&db, &w_id, path, Some(&authed)).await?
};
resp.raw_app = raw_app;
resp.sandbox = policy.sandbox;
resp.app_path = Some(path.to_string());
resp.workspace_id = Some(w_id.to_string());
Ok(Json(resp))
}
async fn get_id_from_secret(
db: &DB,
w_id: &str,
@@ -2285,6 +2757,17 @@ async fn execute_component(
Path((w_id, path)): Path<(String, StripPath)>,
Json(mut payload): Json<ExecuteApp>,
) -> Result<String> {
let path = path.to_path();
// Authorize FIRST, before touching the payload: confine the app embed token (the
// only credential handed to untrusted app JS, carrying `apps:run:<own path>`) to
// the app it was minted for. The route layer can't path-check the apps domain, so
// enforce it here. Scoped to embed tokens only — other callers (anonymous, cookie,
// plain external JWT) keep their existing access; the run is still policy-gated.
if let Some(authed) = opt_authed.as_ref() {
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
check_scopes(authed, || format!("apps:run:{}", path))?;
}
}
// Only honor temp_script_refs for the inline-script preview path:
// preview/editor mode (force_viewer_static_fields set, == `is_preview`),
// raw_code present, and no deployed app_script id — i.e. `wmill app dev`.
@@ -2307,7 +2790,6 @@ async fn execute_component(
_ => {}
};
let path = path.to_path();
let (arc_policy, policy): (Arc<Policy>, Policy);
let policy_triggerables_default = Default::default();
// Preview mode means the request was issued from the editor; the editing
@@ -2771,6 +3253,15 @@ async fn upload_s3_file_from_app(
Query(query): Query<UploadFileToS3Query>,
request: axum::extract::Request,
) -> JsonResult<AppUploadFileResponse> {
// Confine an app embed token (untrusted app JS) to uploading for its OWN app.
// The route is reachable with `apps:run` (RUN_PATH_ACTIONS), so without this a
// token minted for app A could drive app B's upload policy. Mirrors
// execute_component / download_s3_file; other callers are unaffected.
if let Some(authed) = opt_authed.as_ref() {
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
check_scopes(authed, || format!("apps:run:{}", path.to_path()))?;
}
}
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
// `force_viewer_*` lets the caller supply a synthetic upload policy that
// bypasses the deployed app's file_key_regex / resource restrictions.
@@ -2805,6 +3296,7 @@ async fn upload_s3_file_from_app(
.unwrap_or_default(),
}]),
allowed_s3_keys: None,
sandbox: None,
})
} else {
let policy_o = sqlx::query_scalar!(
@@ -3164,6 +3656,7 @@ async fn get_on_behalf_authed_from_app(
on_behalf_of_email: None,
s3_inputs: None,
allowed_s3_keys: Some(force_allowed_s3_keys),
sandbox: None,
}
} else {
// TODO: improve db query to not return uneeded fields
@@ -3186,6 +3679,7 @@ async fn get_on_behalf_authed_from_app(
on_behalf_of_email: None,
s3_inputs: None,
allowed_s3_keys: None,
sandbox: None,
})
};
@@ -3229,34 +3723,46 @@ async fn check_if_allowed_to_access_s3_file_from_app(
return Err(Error::InternalErr(
"Internal error: signature validation is not supported in open source mode".to_string(),
));
} else if opt_authed.is_some() {
} else if opt_authed.as_ref().is_some_and(|authed| {
!windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref())
}) {
// A normal logged-in caller (editor / full session) may fetch any file they
// can reach. An app embed token also carries an identity but represents
// untrusted app JS, so it falls through to the allowlist below instead of
// this bypass — otherwise the app could read arbitrary S3 keys the
// viewer/on-behalf identity can see, beyond its own declared keys/outputs.
Ok(())
} else {
let allowed = policy
.allowed_s3_keys
// Anonymous viewer, or an app embed token: confine to the app's declared S3
// keys, or files produced by THIS app's own component runs. The producing
// identity is the embed viewer for a token, else `anonymous`.
let creator = opt_authed
.as_ref()
.unwrap()
.iter()
.any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage)
|| {
sqlx::query_scalar!(
r#"SELECT EXISTS (
.map(|authed| authed.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
let allowed = policy.allowed_s3_keys.as_ref().is_some_and(|keys| {
keys.iter()
.any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage)
}) || {
sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)
WHERE j.workspace_id = $2
AND (j.kind = 'appscript' OR j.kind = 'preview')
AND j.created_by = 'anonymous'
AND j.created_by = $4
AND c.started_at > now() - interval '3 hours'
AND j.runnable_path LIKE $3 || '/%'
AND c.result @> ('{"s3":"' || $1 || '"}')::jsonb
)"#,
file_query.s3,
w_id,
path,
)
.fetch_one(db)
.await?
.unwrap_or(false)
};
file_query.s3,
w_id,
path,
creator,
)
.fetch_one(db)
.await?
.unwrap_or(false)
};
if !allowed {
Err(Error::BadRequest("File restricted".to_string()))
@@ -3295,6 +3801,15 @@ async fn download_s3_file_from_app(
let path = path.to_path();
// Authorize the app path first: a scoped caller (notably an app embed token,
// which carries `apps:read:<own path>`) may only download files for the app it
// was minted for — otherwise it could read another app's S3 files via that app's
// on-behalf policy. Unscoped sessions / anonymous callers pass through (the
// latter still gated by the policy allowlist in `check_if_allowed_...`).
if let Some(authed) = opt_authed.as_ref() {
check_scopes(authed, || format!("apps:read:{}", path))?;
}
let force_viewer_allowed_s3_keys = if let Some(force_viewer_allowed_s3_keys) =
query.force_viewer_allowed_s3_keys.clone()
{
@@ -3571,3 +4086,253 @@ async fn build_args(
job_id,
))
}
#[cfg(test)]
mod embed_token_tests {
use super::APP_EMBED_SCOPES;
use windmill_api_auth::scopes::check_scopes_for_route;
/// The embed token must reach exactly the endpoints an app needs and nothing
/// else. This locks the allow/deny matrix that confines a malicious or
/// compromised app to app-only routes (WIN-2006).
#[test]
fn embed_scopes_allow_app_routes_and_deny_the_rest() {
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
// Mirror mint_app_embed_token: the per-app path-scoped read + run.
scopes.push("apps:read:u/admin/app".to_string());
scopes.push("apps:run:u/admin/app".to_string());
let scopes = Some(scopes.as_slice());
// Allowed: the routes a running app legitimately calls.
let allowed = [
// Own definition + the public app-serving / execution endpoints.
("/api/w/test/apps/get/p/u/admin/app", "GET"),
("/api/w/test/apps_u/public_app/secret", "GET"),
("/api/w/test/apps_u/get_data/v/secret.js", "GET"),
("/api/w/test/apps_u/public_resource/f/app_themes/t", "GET"),
("/api/w/test/apps_u/execute_component/u/admin/app", "POST"),
// S3 file upload from the app's S3 File Input component: a `run` action
// (RUN_PATH_ACTIONS) so the embed token reaches it; the handler re-checks
// `apps:run:<path>` to confine it to this app, like execute_component.
("/api/w/test/apps_u/upload_s3_file/u/admin/app", "POST"),
// By-id job poll routes (the JobLoader surface) stay allowed.
("/api/w/test/jobs_u/get/some-uuid", "GET"),
("/api/w/test/jobs_u/getupdate/some-uuid", "GET"),
("/api/w/test/jobs_u/getupdate_sse/some-uuid", "GET"),
("/api/w/test/jobs_u/completed/get_result/some-uuid", "GET"),
("/api/w/test/jobs_u/completed/get_timing/some-uuid", "GET"),
// By-id cancel (POST): permitted at the route layer; the handler confines
// it to the app's own jobs (created_by == viewer).
("/api/w/test/jobs_u/queue/cancel/some-uuid", "POST"),
("/api/w/test/users/whoami", "GET"),
// Resource METADATA only (picker list + type schemas) — never values.
("/api/w/test/resources/list", "GET"),
("/api/w/test/resources/exists/u/admin/r", "GET"),
("/api/w/test/resources/type/list", "GET"),
("/api/w/test/folders/listnames", "GET"),
];
for (path, method) in allowed {
assert!(
check_scopes_for_route(scopes, path, method).is_ok(),
"embed token should allow {method} {path}"
);
}
// Denied: anything outside what an app needs, including app management
// (apps:write is intentionally withheld), resource VALUE reads (which can
// hold credentials), and other workspace domains.
let denied = [
("/api/w/test/apps/update/u/admin/app", "POST"),
("/api/w/test/apps/delete/u/admin/app", "DELETE"),
// Workspace app inventory must NOT be reachable (Apps domain is
// default-denied for the embed sentinel; only own-def + apps_u/* allowed).
("/api/w/test/apps/exists/u/admin/app", "GET"),
("/api/w/test/apps/custom_path_exists/foo", "GET"),
(
"/api/w/test/apps/list_paths_from_workspace_runnable/script/u/admin/x",
"GET",
),
("/api/w/test/apps/list", "GET"),
// The embed-token MINT endpoints are public app routes (`apps_u/`) but
// create credentials — denied so a captured embed token can't renew
// itself indefinitely past the 12h expiry (refresh is the embedder's job).
("/api/w/test/apps_u/embed_token/secret", "GET"),
("/api/w/test/apps_u/embed_token_by_custom_path/foo", "GET"),
("/api/w/test/scripts/list", "GET"),
("/api/w/test/variables/list", "GET"),
("/api/w/test/resources/update/u/admin/r", "POST"),
// Resource value reads must NOT be reachable with the embed token.
("/api/w/test/resources/get/u/admin/r", "GET"),
("/api/w/test/resources/get_value/u/admin/r", "GET"),
(
"/api/w/test/resources/get_value_interpolated/u/admin/r",
"GET",
),
("/api/w/test/resources/list_search", "GET"),
// Workspace-wide job enumeration/export must NOT be reachable — an app
// reads only jobs it launched, by id (blocked via the app_embed sentinel).
("/api/w/test/jobs/list", "GET"),
("/api/w/test/jobs/list_filtered_uuids", "GET"),
("/api/w/test/jobs/completed/list", "GET"),
("/api/w/test/jobs/completed/export", "GET"),
("/api/w/test/jobs/queue/list", "GET"),
("/api/w/test/jobs/queue/list_filtered_uuids", "GET"),
("/api/w/test/jobs/queue/export", "GET"),
// Job counts (workspace-wide aggregates) and the capability-minting
// routes (signed resume/approval URLs) are NOT by-id polling — denied.
("/api/w/test/jobs/completed/count", "GET"),
("/api/w/test/jobs/completed/count_jobs", "GET"),
("/api/w/test/jobs/queue/count", "GET"),
("/api/w/test/jobs/job_signature/some-uuid/some-rid", "GET"),
("/api/w/test/jobs/resume_urls/some-uuid/some-rid", "GET"),
// get_root_job_id has no access check in its handler and the app never
// calls it — denied so the token can't probe foreign jobs' flow lineage.
("/api/w/test/jobs_u/get_root_job_id/some-uuid", "GET"),
// `users:read`/`folders:read` exist only for whoami/listnames — every
// other route in those domains is denied via the app_embed sentinel
// (the whole /users and /folders routers are CORS-enabled for the iframe).
("/api/w/test/users/list", "GET"),
("/api/w/test/users/list_usage", "GET"),
("/api/w/test/users/username_to_email/admin", "GET"),
("/api/w/test/folders/list", "GET"),
("/api/w/test/folders/get/myfolder", "GET"),
("/api/w/test/folders/getusage/myfolder", "GET"),
];
for (path, method) in denied {
assert!(
check_scopes_for_route(scopes, path, method).is_err(),
"embed token should deny {method} {path}"
);
}
}
/// `apps:run` satisfies read at the route layer, so `apps/list` / `apps/list_search`
/// pass the route check — that's why those handlers ALSO call
/// `check_scopes(apps:read)`, which uses `ScopeDefinition::includes` (where run
/// does NOT include read). Lock that: no embed scope, including the
/// dynamically-minted path-scoped read, satisfies a domain-level `apps:read`, so
/// the token cannot list all apps' definitions (their full `value`/code).
#[test]
fn embed_scopes_cannot_satisfy_domain_app_read() {
use windmill_api_auth::scopes::ScopeDefinition;
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
// mint_app_embed_token also grants read scoped to the single app path:
scopes.push("apps:read:u/admin/app".to_string());
let required = ScopeDefinition::from_scope_string("apps:read").unwrap();
for s in &scopes {
// The `app_embed` sentinel intentionally doesn't parse as a domain:action
// scope (it grants nothing; it only drives the job-enumeration deny).
let Ok(def) = ScopeDefinition::from_scope_string(s) else {
continue;
};
assert!(
!def.includes(&required),
"embed scope {s} must not satisfy domain-level apps:read (would leak apps/list[_search])"
);
}
// Sanity: a genuine domain-level apps:read token does satisfy it.
assert!(ScopeDefinition::from_scope_string("apps:read")
.unwrap()
.includes(&required));
}
/// The token carries path-scoped `apps:run:<own path>` and `apps:read:<own path>`
/// (NOT unqualified `apps:run`). Every handler that resolves an app and acts on
/// its behalf re-checks the requested path via `ScopeDefinition::includes`, so the
/// token is confined to its OWN app:
/// - `apps:run:<path>` — `execute_component`.
/// - `apps:read:<path>` — `get_app` (apps/get/p), `get_public_app_by_secret`,
/// the EE custom-path `get_public_app_by_custom_path`, and
/// `download_s3_file_from_app`.
/// This blocks cross-app execution, definition reads (by secret / custom path),
/// and S3 file reads through another app's on-behalf policy.
#[test]
fn embed_run_scope_is_path_scoped_to_its_app() {
use windmill_api_auth::scopes::ScopeDefinition;
// The mint must not grant unqualified run (which would include any path).
assert!(
!APP_EMBED_SCOPES.contains(&"apps:run"),
"embed scopes must not include unqualified apps:run"
);
for action in ["run", "read"] {
let own =
ScopeDefinition::from_scope_string(&format!("apps:{action}:u/admin/app")).unwrap();
assert!(
own.includes(
&ScopeDefinition::from_scope_string(&format!("apps:{action}:u/admin/app"))
.unwrap()
),
"apps:{action} must grant its own app"
);
assert!(
!own.includes(
&ScopeDefinition::from_scope_string(&format!("apps:{action}:u/admin/other"))
.unwrap()
),
"apps:{action} must NOT grant another app (cross-app)"
);
}
}
/// `mint_app_embed_token` guards its `create_token_internal` call with
/// `ensure_scopes_within_caller`, so a scope-restricted bearer token cannot
/// bootstrap the broader embed-scope set. Lock that boundary on the exact
/// scope vec the mint builds: rejected for a path-scoped caller, no-op for
/// the unscoped browser session that is the normal embed flow.
#[test]
fn embed_token_mint_is_scope_bounded() {
use windmill_api_auth::{ensure_scopes_within_caller, ApiAuthed};
// Same scope set mint_app_embed_token assembles for an app.
let mut minted: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
minted.push("apps:read:u/admin/app".to_string());
// A caller restricted to a single app read must not widen to the full
// embed set (apps:run, jobs:read, resources:read, ...).
let restricted = ApiAuthed {
scopes: Some(vec!["apps:read:u/admin/app".to_string()]),
..Default::default()
};
assert!(
ensure_scopes_within_caller(&restricted, Some(&minted)).is_err(),
"a path-scoped caller must not mint the broader embed-scope set"
);
// An unscoped session (the normal embed flow) passes — the mint only
// narrows.
let unscoped = ApiAuthed { scopes: None, ..Default::default() };
assert!(ensure_scopes_within_caller(&unscoped, Some(&minted)).is_ok());
}
/// The embed-token endpoints must keep working for legacy apps whose stored
/// policy no longer satisfies the strict `Policy` struct (pre-dating
/// now-required fields): `parse_embed_policy` reads only the sandbox-decision
/// fields, leniently, and treats a missing/unknown `execution_mode` as NOT
/// anonymous (the strictest access interpretation).
#[test]
fn embed_policy_parse_is_lenient() {
use super::parse_embed_policy;
// Quirky legacy policy: triggerables_v2 entry missing required fields,
// no execution_mode at all — must still parse, and absent `sandbox`
// resolves to the unsandboxed default.
let p = parse_embed_policy(r#"{"triggerables_v2": {"x": {}}}"#).unwrap();
assert!(!p.sandbox);
assert!(
!p.anonymous_execution,
"missing execution_mode must not grant anonymous access"
);
// Normal policies map field-for-field.
let p = parse_embed_policy(r#"{"execution_mode": "anonymous", "sandbox": true}"#).unwrap();
assert!(p.anonymous_execution);
assert!(p.sandbox);
// Unknown execution_mode value: lenient parse, but not anonymous.
let p = parse_embed_policy(r#"{"execution_mode": "weird"}"#).unwrap();
assert!(!p.anonymous_execution);
// Invalid JSON still errors.
assert!(parse_embed_policy("not json").is_err());
}
}
+31
View File
@@ -513,6 +513,26 @@ async fn cancel_job_api(
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
) -> error::Result<String> {
// App embed tokens (the sandboxed app iframe) may cancel ONLY jobs they launched
// — their app's component runs, stamped created_by == viewer. cancel_job_api has
// no other per-job ownership check, so without this an embed token (which carries
// the viewer's identity) could cancel any job by id. NotFound (not 403) so the
// untrusted app can't probe job existence.
if let Some(authed) = opt_authed.as_ref() {
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
id,
&w_id
)
.fetch_optional(&db)
.await?;
if created_by.as_deref() != Some(authed.username.as_str()) {
return Err(Error::NotFound(format!("Job {id} not found")));
}
}
}
let tx = db.begin().await?;
let audit_author: AuditAuthor = match opt_authed.as_ref() {
@@ -1007,6 +1027,17 @@ async fn require_job_read_access(
return Ok(());
}
// App embed tokens (the sandboxed app iframe) carry the viewer's identity so the
// app can read its own component runs — which are stamped `created_by == viewer`
// and so already returned above. They must NOT inherit the viewer's *broader*
// job access (share links, folder ACLs, admin RLS): user-authored app JS holds
// this token, and letting it reach any job merely visible to the viewer would
// expose unrelated runs' results/logs. Stop at the launched-by-viewer grant.
// NotFound (not PermissionDenied) so the untrusted app can't probe job existence.
if windmill_api_auth::scopes::has_app_embed_sentinel(authed.scopes.as_deref()) {
return Err(Error::NotFound(format!("Job {job_id} not found")));
}
// `username_override` is derived from the token *label* (`username_override_from_label`),
// which is fully user-controlled with no uniqueness/ownership check (webhook-/http-/
// email-/ws- trigger tokens, `ephemeral-script-end-user-*`, and the generic `label-*`
+32 -5
View File
@@ -546,7 +546,15 @@ pub async fn run_server(
Router::new()
// Reordered alphabetically
.nest("/acls", granular_acls::workspaced_service())
.nest("/apps", apps::workspaced_service(request_size_limit * 5))
// CORS so the opaque-origin in-workspace app viewer (WIN-2006,
// sandboxed /apps/get) can read the app definition by path
// (apps/get/p, apps/embed_token/p) with a scoped embed token.
// Bearer-token-only (no cookies), consistent with the other
// workspaced services the iframe calls.
.nest(
"/apps",
apps::workspaced_service(request_size_limit * 5).layer(cors.clone()),
)
.nest("/assets", windmill_api_assets::workspaced_service())
.nest("/audit", audit::workspaced_service())
.nest("/capture", capture::workspaced_service())
@@ -566,7 +574,13 @@ pub async fn run_server(
"/flow_conversations",
windmill_api_flow_conversations::workspaced_service(),
)
.nest("/folders", folders::workspaced_service())
// CORS so an opaque-origin app iframe (WIN-2006 embed,
// no separate domain) can read folders/listnames with a
// scoped embed token. Consistent with apps_u/jobs_u cors.
.nest(
"/folders",
folders::workspaced_service().layer(cors.clone()),
)
.nest("/folders_history", folder_history::workspaced_service())
.nest("/groups", groups::workspaced_service())
.nest("/groups_history", group_history::workspaced_service())
@@ -616,14 +630,23 @@ pub async fn run_server(
path_autocomplete::workspaced_service(),
)
.nest("/raw_apps", raw_apps::workspaced_service())
.nest("/resources", resources::workspaced_service())
// CORS so the opaque-origin app iframe can read
// resources/list, resources/type/* with a scoped token.
.nest(
"/resources",
resources::workspaced_service().layer(cors.clone()),
)
.nest("/shared_ui", workspace_shared_ui::workspaced_service())
.nest("/schedules", windmill_api_schedule::workspaced_service())
.nest("/scripts", scripts::workspaced_service())
.nest("/trash", trash::workspaced_service())
.nest(
"/users",
users::workspaced_service().layer(Extension(argon2.clone())),
// CORS so the opaque-origin app iframe can read
// users/whoami with a scoped embed token.
users::workspaced_service()
.layer(Extension(argon2.clone()))
.layer(cors.clone()),
)
.nest("/variables", variables::workspaced_service())
.nest("/volumes", volumes_oss::workspaced_service())
@@ -729,7 +752,11 @@ pub async fn run_server(
.nest("/apps_u", {
#[cfg(feature = "enterprise")]
{
apps_oss::global_unauthed_service()
// CORS so the opaque-origin app viewer (WIN-2006 embed, no
// separate domain) can load a custom-path public app via
// public_app_by_custom_path cross-origin. Consistent with
// the workspaced /w/{workspace_id}/apps_u mount below.
apps_oss::global_unauthed_service().layer(cors.clone())
}
#[cfg(not(feature = "enterprise"))]
+4
View File
@@ -53,3 +53,7 @@ futures.workspace = true
chrono.workspace = true
reqwest.workspace = true
anyhow.workspace = true
base64.workspace = true
[dev-dependencies]
magic-crypt.workspace = true
+100 -2
View File
@@ -14,8 +14,8 @@ use windmill_common::db::DB;
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
use crate::secret_backend_ext::{
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
store_secret_value,
delete_secret_from_backend, get_secret_value, is_external_stored_value, is_vault_stored_value,
rename_vault_secret, store_secret_value,
};
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
use windmill_common::webhook::{WebhookMessage, WebhookShared};
@@ -25,6 +25,7 @@ use axum::{
routing::{delete, get, post},
Json, Router,
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use futures::future::try_join_all;
use hyper::StatusCode;
use serde_json::Value;
@@ -535,6 +536,35 @@ async fn check_path_conflict(db: &DB, w_id: &str, path: &str) -> Result<()> {
return Ok(());
}
/// Reject a secret value flagged as already-encrypted (`already_encrypted=true`)
/// that is not actually workspace-key ciphertext — e.g. plaintext mistakenly
/// pushed as encrypted. Storing plaintext in the encrypted `value` column
/// silently bricks the variable: every later read fails to decrypt it.
///
/// The check is purely structural and never decrypts, so it cannot act as a
/// decryption/padding oracle for a caller who can write but not read secrets.
/// `encrypt` (AES-256-CBC) always yields standard base64 decoding to a non-zero
/// multiple of the 16-byte block size; anything else cannot be our ciphertext.
/// Values stored by an external backend ($vault:/$aws_sm:/$azure_kv: markers)
/// are not workspace ciphertext and are passed through untouched.
fn validate_already_encrypted_secret(path: &str, value: &str) -> Result<()> {
if is_external_stored_value(value) {
return Ok(());
}
let looks_like_ciphertext = STANDARD
.decode(value)
.map(|bytes| !bytes.is_empty() && bytes.len() % 16 == 0)
.unwrap_or(false);
if !looks_like_ciphertext {
return Err(Error::BadRequest(format!(
"Variable {path} was sent as already-encrypted (already_encrypted=true) but its \
value is not valid workspace-encrypted ciphertext. To push a plaintext secret, \
send it without already_encrypted (CLI: use --plain-secrets) so it gets encrypted."
)));
}
Ok(())
}
async fn create_variable(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -585,6 +615,11 @@ async fn create_variable(
// Use secret backend for encryption (supports both DB and Vault)
store_secret_value(&db, &w_id, &variable.path, &plain).await?
} else {
if variable.is_secret {
// already_encrypted == true: value is stored verbatim, so it must be
// ciphertext and not plaintext mislabeled as encrypted.
validate_already_encrypted_secret(&variable.path, &variable.value)?;
}
variable.value
};
@@ -1082,6 +1117,11 @@ async fn update_variable(
// Store at target_path (new path if renaming, otherwise current path)
store_secret_value(&db, &w_id, target_path, &plain).await?
} else {
if is_secret {
// already_encrypted == true: value is stored verbatim, so it must
// be ciphertext and not plaintext mislabeled as encrypted.
validate_already_encrypted_secret(target_path, &nvalue)?;
}
nvalue
};
sqlb.set_str("value", &value);
@@ -1513,3 +1553,61 @@ pub async fn get_value_internal<'a>(
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
use magic_crypt::MagicCryptTrait;
#[test]
fn accepts_real_workspace_ciphertext() {
// The exact shape produced by `encrypt` (AES-256-CBC, base64).
let mc = magic_crypt::new_magic_crypt!("a-test-workspace-key", 256);
for plain in [
"",
"original-secret",
"some: plaintext\n",
"a".repeat(500).as_str(),
] {
let ciphertext = mc.encrypt_str_to_base64(plain);
assert!(
validate_already_encrypted_secret("f/x/cfg", &ciphertext).is_ok(),
"should accept genuine ciphertext for plaintext {plain:?}: {ciphertext}"
);
}
}
#[test]
fn rejects_plaintext_mislabeled_as_encrypted() {
// Plaintext mislabeled as encrypted: storing it verbatim would make the
// variable undecryptable on every read, so it must be rejected.
for plaintext in [
"some: plaintext\n",
"original-secret",
"hunter2",
"{\"a\": 1}",
"not base64!!",
" leading-space",
] {
assert!(
validate_already_encrypted_secret("f/x/cfg", plaintext).is_err(),
"should reject plaintext mislabeled as encrypted: {plaintext:?}"
);
}
}
#[test]
fn rejects_empty_and_non_block_aligned() {
// Valid base64 but not a whole number of AES blocks -> cannot be our ciphertext.
assert!(validate_already_encrypted_secret("p", "").is_err());
assert!(validate_already_encrypted_secret("p", "dGVzdA==").is_err()); // "test" -> 4 bytes
}
#[test]
fn passes_through_external_backend_markers() {
// External secret backends store $-prefixed markers, not workspace ciphertext.
for marker in ["$vault:f/x/cfg", "$aws_sm:f/x/cfg", "$azure_kv:f/x/cfg"] {
assert!(validate_already_encrypted_secret("f/x/cfg", marker).is_ok());
}
}
}
+42 -2
View File
@@ -255,6 +255,14 @@ pub async fn do_duckdb(
} else {
None
};
// Parse the signature from the ORIGINAL script: managed materialize wraps
// the trailing SELECT and strips line comments, which drops the
// `-- $name (type)` arg declarations while their `$name` references
// survive in the embedded SELECT. Parsing args here (pre-wrap) keeps them
// declared so they are still bound — and s3object args translated to
// `s3://` URIs — at run time.
let sig = parse_duckdb_sig(query)?.args;
let materialized_query;
let query: &str = match &materialize {
Some((Some(rewritten), _)) => {
@@ -263,8 +271,6 @@ pub async fn do_duckdb(
}
_ => query,
};
let sig = parse_duckdb_sig(query)?.args;
let mut job_args = build_args_values(job, client, conn).await?;
let reserved_variables =
@@ -1106,6 +1112,40 @@ mod tests {
);
}
// Managed `// materialize` may take SQL args (e.g. an s3object uploaded on
// the run form). The wrap strips line comments — including the
// `-- $name (type)` declarations — so the executor parses the signature from
// the original script (done above, before the rewrite) while the `$name`
// references survive inside the wrapped SELECT. This pins both halves of that
// contract so a regression that drops either is caught.
#[test]
fn materialize_preserves_sql_args() {
let script = "-- materialize ducklake://main/rows\n\
-- $file (s3object)\n\
SELECT * FROM read_json_auto($file)";
// The signature is recoverable from the original (un-wrapped) script.
let sig = parse_duckdb_sig(script).expect("sig parses").args;
let file_arg = sig
.iter()
.find(|a| a.name == "file")
.expect("`$file` declared");
assert_eq!(file_arg.otyp.as_deref(), Some("s3object"));
// The wrapped query still references `$file`, so the parsed sig binds it.
let (rewritten, _) = build_materialized_query(script, None)
.expect("materialize builds")
.expect("materialize present");
let rewritten = rewritten.expect("managed mode rewrites the query");
assert!(
rewritten.contains("$file"),
"wrapped query must keep the `$file` reference, got:\n{rewritten}"
);
// The declaration comment is gone (wrap strips line comments) — which is
// exactly why the sig must come from the original, not the rewrite.
assert!(!rewritten.contains("-- $file"));
}
// Tests for parse_attach_db_resource function
#[test]
fn test_parse_attach_db_resource_postgres_res_prefix() {
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.736.0";
export const VERSION = "v1.737.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+74 -4
View File
@@ -99,6 +99,31 @@ export interface VariableFile {
is_oauth?: boolean;
}
/**
* Whether `value` has the structural shape of a workspace-encrypted secret
* (the form produced by `sync pull` without --plain-secrets), as opposed to a
* plaintext value a user authored by hand.
*
* Mirrors the server guard (windmill-store/src/variables.rs): workspace
* ciphertext (AES-256-CBC, base64) is standard base64 decoding to a non-zero
* multiple of the 16-byte block size. External secret-backend markers
* ($vault:/$aws_sm:/$azure_kv:) are stored verbatim too, so they count as
* already-encrypted. This is a shape check only it never decrypts.
*/
export function looksLikeWorkspaceCiphertext(value: string): boolean {
if (
value.startsWith("$vault:") ||
value.startsWith("$aws_sm:") ||
value.startsWith("$azure_kv:")
) {
return true;
}
if (value.length === 0 || value.length % 4 !== 0) return false;
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
const decodedLen = Buffer.from(value, "base64").length;
return decodedLen > 0 && decodedLen % 16 === 0;
}
export async function pushVariable(
workspace: string,
remotePath: string,
@@ -106,6 +131,11 @@ export async function pushVariable(
localVariable: VariableFile,
plainSecrets: boolean,
wsSpecific?: boolean,
// Whether a secret->non-secret downgrade may be applied. Only an authoritative
// single-file `variable push` sets this. Bulk `sync push` leaves it false: a
// pulled secret's spec value is ciphertext, and demoting it would store that
// ciphertext verbatim as a visible non-secret value.
allowSecretDowngrade: boolean = false,
): Promise<void> {
remotePath = removeType(remotePath, "variable");
log.debug(`Processing local variable ${remotePath}`);
@@ -130,14 +160,26 @@ export async function pushVariable(
log.debug(`Variable ${remotePath} is not up-to-date, updating`);
// Apply is_secret only when it differs from the remote (the value is always
// sent, so the server allows the flag change). Upgrades (non-secret->secret)
// always apply; downgrades only when explicitly allowed (single-file push) —
// see allowSecretDowngrade. `undefined` leaves the flag untouched.
let nextIsSecret: boolean | undefined = undefined;
if (localVariable.is_secret !== variable.is_secret) {
if (localVariable.is_secret) {
nextIsSecret = true;
} else if (allowSecretDowngrade) {
nextIsSecret = false;
}
}
await wmill.updateVariable({
workspace,
path: remotePath.replaceAll(SEP, "/"),
alreadyEncrypted: !plainSecrets,
requestBody: {
...localVariable,
is_secret:
localVariable.is_secret && !variable.is_secret ? true : undefined,
is_secret: nextIsSecret,
...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}),
},
});
@@ -174,12 +216,40 @@ async function push(
log.info(colors.bold.yellow("Pushing variable..."));
const local = parseFromFile(filePath) as VariableFile;
// A secret value in a single-file push is authored by the user and is
// therefore plaintext that must be encrypted server-side — unless it has the
// shape of workspace ciphertext (a value round-tripped from `sync pull`).
// Pushing plaintext as already-encrypted would brick the variable. An explicit
// --plain-secrets always forces the plaintext (encrypt) path.
let plainSecrets = opts.plainSecrets ?? false;
if (opts.plainSecrets === undefined && local.is_secret) {
if (!looksLikeWorkspaceCiphertext(local.value)) {
log.info(
colors.yellow(
"Secret value is not in encrypted form; pushing as plaintext to be encrypted server-side (pass --plain-secrets to silence)."
)
);
plainSecrets = true;
} else {
// The value has the shape of workspace ciphertext, so it's stored as-is.
// A plaintext secret that coincidentally looks like ciphertext (e.g. a
// base64 token) would be stored unreadable, so surface the assumption.
log.warn(
"Secret value looks already-encrypted; pushing it as-is. If it is a plaintext secret, re-run with --plain-secrets so it gets encrypted."
);
}
}
await pushVariable(
workspace.workspaceId,
remotePath,
undefined,
parseFromFile(filePath),
opts.plainSecrets ?? false
local,
plainSecrets,
undefined,
true // single-file push is authoritative: allow secret->non-secret downgrade
);
log.info(colors.bold.underline.green(`Variable ${remotePath} pushed`));
}
+5 -5
View File
@@ -104,25 +104,25 @@ export async function requireLogin(
// 403 means the token authenticated but lacks scope — re-issuing
// won't help. Keep this distinct from the 401 message so the user
// doesn't waste time reproducing the token.
log.info(colors.red(
log.infoStderr(colors.red(
`Permission denied: the token is valid but lacks the required scope.${bodyStr ? `\n${bodyStr}` : ""}`
));
} else if (status === 401) {
log.info(colors.red(
log.infoStderr(colors.red(
`Could not authenticate with the provided credentials. Please check your --token and --base-url and try again.${bodyStr ? `\n${bodyStr}` : ""}`
));
} else {
log.info(colors.red(
log.infoStderr(colors.red(
`Request failed (${status ?? "unknown"}): ${bodyStr}`
));
}
return process.exit(1);
}
log.info(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
log.infoStderr(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
return process.exit(1);
}
log.info(
log.infoStderr(
"! Could not reach API given existing credentials. Attempting to reauth..."
);
const newToken = await loginInteractive(workspace.remote);
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.736.0";
export const VERSION = "1.737.0";
+29 -29
View File
@@ -57,7 +57,7 @@ async function selectFromMultipleProfiles(
(p) => p.name === lastUsedProfileName
);
if (lastUsedProfile) {
log.info(
log.infoStderr(
colors.green(
`Using last used profile '${lastUsedProfile.name}' for ${context}`
)
@@ -69,7 +69,7 @@ async function selectFromMultipleProfiles(
// No last used or it no longer exists - prompt for selection
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
const selectedProfile = profiles[0];
log.info(
log.infoStderr(
colors.yellow(
`Multiple profiles found for ${context}. Using first available profile: '${selectedProfile.name}'`
)
@@ -87,7 +87,7 @@ async function selectFromMultipleProfiles(
return selectedProfile;
}
log.info(
log.infoStderr(
colors.yellow(`\nMultiple workspace profiles found for ${context}:`)
);
@@ -125,14 +125,14 @@ async function createWorkspaceProfileInteractively(
): Promise<Workspace | undefined> {
// Log appropriate message based on context
if (!context.isForked) {
log.info(
log.infoStderr(
colors.yellow(
`\nNo workspace profile found for branch '${context.rawBranch}'\n` +
`(${normalizedBaseUrl}, ${workspaceId})`
)
);
} else {
log.info(
log.infoStderr(
colors.yellow(
`\nNo workspace profile was found for this forked workspace\n` +
`(${normalizedBaseUrl}, ${workspaceId})`
@@ -141,7 +141,7 @@ async function createWorkspaceProfileInteractively(
}
if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) {
log.info(
log.infoStderr(
"Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first."
);
return undefined;
@@ -187,12 +187,12 @@ async function createWorkspaceProfileInteractively(
opts.configDir
);
log.info(
log.infoStderr(
colors.green(
`✓ Created profile '${profileName}' for ${workspaceId} on ${normalizedBaseUrl}`
)
);
log.info(colors.green(`✓ Profile '${profileName}' is now active`));
log.infoStderr(colors.green(`✓ Profile '${profileName}' is now active`));
return newWorkspace;
}
@@ -244,7 +244,7 @@ async function tryResolveWorkspace(
`workspace '${opts.workspace}'`,
opts.configDir
);
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${selected.name}' for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})`
)
@@ -254,7 +254,7 @@ async function tryResolveWorkspace(
}
// No matching profile — offer to create one
log.info(
log.infoStderr(
`No profile found for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})`
);
const ws = await createWorkspaceProfileInteractively(
@@ -309,7 +309,7 @@ export async function tryResolveBranchWorkspace(
wsEntry = config.workspaces?.[workspaceNameOverride] as WorkspaceEntryConfig | undefined;
if (wsEntry) {
wsName = workspaceNameOverride;
log.info(`Using workspace override: ${workspaceNameOverride}`);
log.infoStderr(`Using workspace override: ${workspaceNameOverride}`);
}
} else {
// Only try branch-based resolution if in a Git repository
@@ -328,7 +328,7 @@ export async function tryResolveBranchWorkspace(
const branchToLookup = originalBranchIfForked ?? rawBranch;
if (originalBranchIfForked) {
log.info(
log.infoStderr(
`Using original branch \`${originalBranchIfForked}\` for finding workspace from workspaces section in wmill.yaml`
);
}
@@ -346,7 +346,7 @@ export async function tryResolveBranchWorkspace(
if (!wsEntry.baseUrl) {
if (workspaceNameOverride) {
// User explicitly asked for this workspace but it has no baseUrl
log.warn(
log.warnStderr(
`⚠️ Workspace '${wsName}' has no baseUrl configured. Cannot resolve a profile.\n` +
` Add baseUrl to workspace '${wsName}' in wmill.yaml, or use --base-url flag.`
);
@@ -370,7 +370,7 @@ export async function tryResolveBranchWorkspace(
reason = `matched current git branch '${rawBranch}'`;
}
log.info(
log.infoStderr(
`Using workspace '${wsName}' (${reason}) → ${workspaceId} on ${baseUrl}`
);
@@ -406,7 +406,7 @@ export async function tryResolveBranchWorkspace(
if (matchingProfiles.length === 1) {
selectedProfile = matchingProfiles[0];
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${selectedProfile.name}' for workspace '${wsName}' with workspace id \`${workspaceId}\``
)
@@ -424,7 +424,7 @@ export async function tryResolveBranchWorkspace(
(p) => p.name === lastUsedName
);
if (lastUsedProfile) {
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${lastUsedProfile.name}' for workspace '${wsName}' (last used)`
)
@@ -449,7 +449,7 @@ export async function tryResolveBranchWorkspace(
opts.configDir
);
log.info(
log.infoStderr(
colors.green(
`Using workspace profile '${selectedProfile.name}' for workspace '${wsName}'`
)
@@ -459,7 +459,7 @@ export async function tryResolveBranchWorkspace(
if (workspaceIdIfForked) {
selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`;
selectedProfile.workspaceId = workspaceIdIfForked;
log.info(
log.infoStderr(
`Using fork workspace \`${workspaceIdIfForked}\` (parent: \`${workspaceId}\`) from branch \`${rawBranch}\``
);
}
@@ -480,7 +480,7 @@ export async function resolveWorkspace(
try {
normalizedBaseUrl = new URL(opts.baseUrl).toString();
} catch (error) {
log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`));
log.infoStderr(colors.red(`Invalid base URL: ${opts.baseUrl}`));
return process.exit(-1);
}
@@ -514,7 +514,7 @@ export async function resolveWorkspace(
if (existingWorkspace) {
if (existingWorkspace.remote !== normalizedBaseUrl) {
log.info(
log.infoStderr(
colors.red(
`Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}`
)
@@ -535,7 +535,7 @@ export async function resolveWorkspace(
token: opts.token,
};
} else {
log.info(
log.infoStderr(
colors.red(
"If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)."
)
@@ -555,7 +555,7 @@ export async function resolveWorkspace(
if (workspaceNameOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) {
return workspace;
} else {
log.info(
log.infoStderr(
`Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`. Use --workspace to override.`
);
}
@@ -572,9 +572,9 @@ export async function resolveWorkspace(
if (suggestions.length > 0) {
msg += ` Did you mean: ${suggestions.map((s) => `"${s.name}"`).join(", ")}?`;
}
log.info(colors.red.bold(msg));
log.infoStderr(colors.red.bold(msg));
if (profiles.length > 0) {
log.info("\nAvailable workspaces:");
log.infoStderr("\nAvailable workspaces:");
new Table()
.header(["name", "remote", "workspace id"])
.padding(2)
@@ -620,12 +620,12 @@ export async function resolveWorkspace(
if (wsNames.length === 1) {
pickedWsName = wsNames[0];
log.info(
log.infoStderr(
`Auto-selected workspace '${pickedWsName}' (only workspace in config).\n` +
`Use --workspace to override or 'wmill workspace bind' to add more workspaces.`
);
} else if (process.stdin.isTTY) {
log.info(
log.infoStderr(
`Multiple workspaces configured but none matched the current context.\n` +
`Configured workspaces:\n${wsListStr}\n` +
`Use --workspace to skip this prompt.`
@@ -675,7 +675,7 @@ export async function resolveWorkspace(
try {
normalizedBaseUrl = new URL(envBaseUrl).toString();
} catch {
log.info(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`));
log.infoStderr(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`));
return process.exit(-1);
}
log.debug(
@@ -691,7 +691,7 @@ export async function resolveWorkspace(
return ws;
}
log.info(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one."));
log.infoStderr(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one."));
return process.exit(-1);
}
@@ -746,7 +746,7 @@ export async function tryResolveVersion(
export function validatePath(path: string): boolean {
if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) {
log.info(
log.infoStderr(
colors.red(
"Given remote path looks invalid. Remote paths are typically of the form <u|g|f>/<username|group|folder>/..."
)
+15
View File
@@ -21,11 +21,26 @@ export function info(msg: unknown) {
console.log(`\x1b[34m${String(msg)}\x1b[39m`);
}
// Like `info` but written to stderr, for diagnostics (e.g. the workspace-profile
// banner printed on every command) that must not pollute stdout when a command's
// data output is piped or redirected (e.g. `wmill variable get path > file`).
export function infoStderr(msg: unknown) {
if (silentMode) return;
console.error(`\x1b[34m${String(msg)}\x1b[39m`);
}
export function warn(msg: unknown) {
if (silentMode) return;
console.log(`\x1b[33m${String(msg)}\x1b[39m`);
}
// Like `warn` but written to stderr; see `infoStderr` for why diagnostics must
// not land on stdout.
export function warnStderr(msg: unknown) {
if (silentMode) return;
console.error(`\x1b[33m${String(msg)}\x1b[39m`);
}
export function error(msg: unknown) {
console.error(`\x1b[31m${String(msg)}\x1b[39m`);
}
+4 -4
View File
@@ -10,7 +10,7 @@ import * as http from "node:http";
export async function loginInteractive(remote: string) {
let token: string | undefined;
if (!process.stdin.isTTY) {
log.info("Not a TTY, can't login interactively.");
log.infoStderr("Not a TTY, can't login interactively.");
return undefined;
}
if (
@@ -55,7 +55,7 @@ export async function browserLogin(
const port = await getPort.default({ port: env });
if (port == undefined) {
log.info(colors.red.underline("failed to aquire port"));
log.infoStderr(colors.red.underline("failed to aquire port"));
return undefined;
}
@@ -79,7 +79,7 @@ export async function browserLogin(
});
const url = `${baseUrl}user/cli?port=${port}`;
log.info(`Login by going to ${url}`);
log.infoStderr(`Login by going to ${url}`);
try {
open.default(url).catch((error) => {
@@ -88,7 +88,7 @@ export async function browserLogin(
);
});
log.info("Opened browser for you");
log.infoStderr("Opened browser for you");
} catch (error) {
console.error(
`Failed to open browser, please navigate to ${url}, error: ${error}`
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import { looksLikeWorkspaceCiphertext } from "../src/commands/variable/variable.ts";
// =============================================================================
// looksLikeWorkspaceCiphertext drives whether single-file `variable push` treats
// a secret's value as already-encrypted (store verbatim) or as plaintext to be
// encrypted server-side. It must agree with the server guard
// (validate_already_encrypted_secret in windmill-store/src/variables.rs): a value
// is "ciphertext shaped" iff it is an external-backend marker, or standard base64
// decoding to a non-zero multiple of the AES block size (16 bytes).
// =============================================================================
test("treats workspace-ciphertext-shaped values as already-encrypted", () => {
const ciphertextShaped = [
"MpYeXnSBBF7dzI6K8J89xQ==", // real magic_crypt output: 16 bytes
Buffer.alloc(16, 7).toString("base64"), // 16 bytes
Buffer.alloc(32, 7).toString("base64"), // 32 bytes
"$vault:f/x/cfg",
"$aws_sm:f/x/cfg",
"$azure_kv:f/x/cfg",
];
for (const value of ciphertextShaped) {
expect(looksLikeWorkspaceCiphertext(value)).toBe(true);
}
});
test("treats hand-authored plaintext as NOT already-encrypted", () => {
const plaintext = [
"some: plaintext\n", // space, colon, newline
"original-secret", // hyphen, not length % 4
"hunter2",
'{"a": 1}',
"", // empty
"dGVzdA==", // valid base64 but decodes to 4 bytes (not % 16)
Buffer.alloc(17, 7).toString("base64"), // 17 bytes (not % 16)
"$omething-plain", // starts with $ but is not a real backend marker
];
for (const value of plaintext) {
expect(looksLikeWorkspaceCiphertext(value)).toBe(false);
}
});
+97
View File
@@ -219,6 +219,103 @@ describe("variable", () => {
});
});
test("push encrypts a plaintext secret value (no --plain-secrets) and round-trips", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const varPath = `f/test/sec_push_${uniqueId}`;
// Existing secret variable (server-encrypted).
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: varPath,
value: "original-secret",
is_secret: true,
description: "",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
// A hand-authored spec file: plaintext value, is_secret: true. Pushing it
// without --plain-secrets must encrypt the value server-side, not store the
// plaintext verbatim as ciphertext (which would make every read fail).
const specPath = join(tempDir, "v.yaml");
await writeFile(
specPath,
`value: |\n some: plaintext\nis_secret: true\ndescription: ""\n`,
"utf-8"
);
const pushResult = await backend.runCLICommand(
["variable", "push", specPath, varPath],
tempDir
);
expect(pushResult.code).toEqual(0);
// The value must decrypt cleanly to the pushed plaintext.
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.is_secret).toBe(true);
expect(varData.value).toBe("some: plaintext\n");
});
});
test("push flips is_secret from true to false", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const varPath = `f/test/sec_down_${uniqueId}`;
const createResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: varPath,
value: "original-secret",
is_secret: true,
description: "",
}),
}
);
expect(createResp.status).toBeLessThan(300);
await createResp.text();
const specPath = join(tempDir, "v_down.yaml");
await writeFile(
specPath,
`value: "now-public"\nis_secret: false\ndescription: ""\n`,
"utf-8"
);
const pushResult = await backend.runCLICommand(
["variable", "push", specPath, varPath],
tempDir
);
expect(pushResult.code).toEqual(0);
const apiResp = await backend.apiRequest!(
`/api/w/${backend.workspace}/variables/get/${varPath}?decrypt_secret=true`
);
expect(apiResp.status).toEqual(200);
const varData = await apiResp.json();
expect(varData.is_secret).toBe(false);
expect(varData.value).toBe("now-public");
});
});
test("pull retrieves variables into local files", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.736.0",
"version": "1.737.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.736.0",
"version": "1.737.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.736.0",
"version": "1.737.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+102 -2
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
@@ -23,6 +23,106 @@
}
</style>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// WIN-2006: when the app is rendered inside an opaque-origin (sandboxed,
// no allow-same-origin) iframe, Web Storage access throws a SecurityError.
// Install an in-memory shim before the SvelteKit app boots so the app (and
// Windmill SPA code running inside the frame) keeps working within the
// session. No-op in normal real-origin contexts, so zero blast radius.
;(function () {
// `onOp` (localStorage only) relays each mutation up to the embedder,
// which backs a single store shared across all apps. sessionStorage
// stays in-memory/session-only.
function shim(onOp) {
var m = {}
return {
__hydrate: function (obj) {
if (obj) for (var k in obj) m['' + k] = '' + obj[k]
},
getItem: function (k) {
k = '' + k
return Object.prototype.hasOwnProperty.call(m, k) ? m[k] : null
},
setItem: function (k, v) {
m['' + k] = '' + v
if (onOp) onOp({ op: 'set', key: '' + k, value: '' + v })
},
removeItem: function (k) {
delete m['' + k]
if (onOp) onOp({ op: 'remove', key: '' + k })
},
clear: function () {
for (var k in m) delete m[k]
if (onOp) onOp({ op: 'clear' })
},
key: function (i) {
var ks = Object.keys(m)
return i < ks.length ? ks[i] : null
},
get length() {
return Object.keys(m).length
}
}
}
try {
window.localStorage.getItem('__wm_probe__')
} catch (e) {
var framed = window.parent !== window
function relayOp(o) {
if (framed)
try {
window.parent.postMessage(
{ type: 'wm_ls_op', op: o.op, key: o.key, value: o.value },
'*'
)
} catch (_) {}
}
var ls = shim(framed ? relayOp : null)
try {
Object.defineProperty(window, 'localStorage', { value: ls, configurable: true })
} catch (_) {}
try {
Object.defineProperty(window, 'sessionStorage', {
value: shim(null),
configurable: true
})
} catch (_) {}
// Persistence: ask the embedder for the shared store and hydrate the
// in-memory localStorage when it arrives.
if (framed) {
window.addEventListener('message', function (ev) {
if (ev.source === window.parent && ev.data && ev.data.type === 'wm_ls_hydrate') {
ls.__hydrate(ev.data.data)
}
})
try {
window.parent.postMessage({ type: 'wm_ls_req' }, '*')
} catch (_) {}
}
// `document.cookie` also throws in an opaque origin; back it with an
// in-memory jar so reads don't crash apps. This is NOT the real
// session cookie (unreachable here) — just an isolated client-side store.
try {
var jar = {}
Object.defineProperty(Document.prototype, 'cookie', {
configurable: true,
get: function () {
return Object.keys(jar)
.map(function (k) {
return k + '=' + jar[k]
})
.join('; ')
},
set: function (v) {
var p = String(v).split(';')[0]
var i = p.indexOf('=')
if (i > -1) jar[p.slice(0, i).trim()] = p.slice(i + 1).trim()
}
})
} catch (_) {}
}
})()
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
@@ -56,7 +156,7 @@
/>
<polygon
class="st3"
points="136.93,132.47 116.46,167.93 73.82,241.78 130.71,241.78 144.9,217.2 180.13,156.18 193.82,132.46
points="136.93,132.47 116.46,167.93 73.82,241.78 130.71,241.78 144.9,217.2 180.13,156.18 193.82,132.46
"
/>
<polygon
+22
View File
@@ -368,6 +368,15 @@
divEl?.classList.add('hidden')
}
// Mirrors the value Monaco's model currently holds, as last synced through
// `code`. The external-`code`→model effect below reflects only when `code`
// diverges from this sentinel, i.e. when `code` was set by an outside writer
// (draft load, template reset) rather than echoed back from the model. Writes
// that go straight to the model (AI chat apply, collab) update `code` via the
// debounced change handler, which keeps this sentinel in lockstep — so the
// effect never reflects a stale `code` over a model that moved ahead.
let lastReflectedCode = code
export function setCode(ncode: string, noHistory: boolean = false): void {
// Track whether the code actually changed before updating.
const changed = code != ncode
@@ -392,6 +401,9 @@
editor.pushUndoStop()
}
}
// The model now holds `ncode`; record it so the reflect effect treats this
// as already-synced and doesn't write it back.
lastReflectedCode = ncode
// Dispatch change immediately when code actually changed. This ensures
// callers like the Reset button and copilot trigger on:change handlers.
// The debounced onDidChangeModelContent handler will no-op since code
@@ -425,6 +437,10 @@
return
}
code = ncode
// `code` was just echoed from the model, so keep the sentinel aligned —
// this is what prevents the reflect effect from racing the model during a
// burst of in-editor edits (e.g. an AI chat apply).
lastReflectedCode = ncode
dispatch('change', ncode)
}
@@ -2020,6 +2036,12 @@
const next = code ?? ''
const ed = editor
if (!ed) return
// Only reflect genuine external `code` changes. When `code` merely echoed a
// model edit (typing, AI chat apply, collab), `lastReflectedCode` already
// matches and we skip — otherwise a debounced echo could overwrite a model
// that has since moved further ahead, reverting the newer edit.
if (code === lastReflectedCode) return
lastReflectedCode = code
untrack(() => {
if (ed.getValue() === next) return
const model = ed.getModel()
@@ -13,7 +13,7 @@
import { twMerge } from 'tailwind-merge'
import type { Output } from '../../rx'
import ResolveNavbarItemPath from './ResolveNavbarItemPath.svelte'
import { urlParamsToObject } from '$lib/utils'
import { urlParamsToObject, WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
interface Props {
navbarItem: NavbarItem
@@ -54,8 +54,22 @@
let resolvedHidden: boolean | undefined = $state(undefined)
function extractPathDetails() {
const url = window.location.pathname + window.location.search + window.location.hash
const processedUrl = url.replace('/apps/edit/', '').replace('/apps/get/', '')
// Drop Windmill transport params (wm_embed, …) so they don't poison the
// comparison against the item's resolved path.
const params = new URLSearchParams(window.location.search)
const reserved: string[] = []
params.forEach((_v, k) => {
if (WINDMILL_RESERVED_QUERY_PARAMS.has(k)) reserved.push(k)
})
reserved.forEach((k) => params.delete(k))
const qs = params.toString()
const url = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash
// `/app_embed/{workspace}/` is the opaque in-workspace viewer route
// (WIN-2006) — same app-path suffix as `/apps/get/`.
const processedUrl = url
.replace('/apps/edit/', '')
.replace('/apps/get/', '')
.replace(/^\/app_embed\/[^/]+\//, '')
return processedUrl
}
@@ -86,7 +86,10 @@
}
const resolvedConfig = $state(
initConfig(components['dbexplorercomponent'].initialData.configuration, untrack(() => configuration))
initConfig(
components['dbexplorercomponent'].initialData.configuration,
untrack(() => configuration)
)
)
let timeoutInput: number | undefined = undefined
@@ -180,18 +183,22 @@
)
}
let outputs = initOutput($worldStore, untrack(() => id), {
selectedRowIndex: 0,
selectedRow: {},
selectedRows: [] as any[],
result: [] as any[],
inputs: {},
loading: false,
page: 0,
newChange: { row: 0, column: '', value: undefined },
ready: undefined as boolean | undefined,
openedModalRow: {}
})
let outputs = initOutput(
$worldStore,
untrack(() => id),
{
selectedRowIndex: 0,
selectedRow: {},
selectedRows: [] as any[],
result: [] as any[],
inputs: {},
loading: false,
page: 0,
newChange: { row: 0, column: '', value: undefined },
ready: undefined as boolean | undefined,
openedModalRow: {}
}
)
let lastResource: string | undefined = undefined
@@ -260,9 +267,7 @@
resolvedConfig.type,
{
table: {
selectOptions: dbSchemas
? await getTablesByResource(dbSchemas, dbtype, dbPath, $workspaceStore!)
: [],
selectOptions: dbSchemas ? await getTablesByResource(dbSchemas, dbtype) : [],
loading: false
}
}
@@ -1,4 +1,4 @@
import { JobService, ResourceService } from '$lib/gen'
import { JobService } from '$lib/gen'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import type { DbInput } from '$lib/components/dbTypes'
@@ -39,17 +39,9 @@ export async function loadTableMetaData(
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
const dbArg = getDatabaseArg(input)
// MySQL needs the database name for metadata queries
let databaseName: string | undefined
if (input.type === 'database' && input.resourceType === 'mysql') {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: input.resourcePath
})) as any
databaseName = resourceObj?.database
}
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table, databaseName }, ducklake)
// MySQL: the metadata query resolves the database name server-side (it falls
// back to `DATABASE()`), so we don't read the resource value client-side for it.
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table }, ducklake)
const job = await JobService.runScriptPreview({
workspace,
@@ -106,22 +98,10 @@ export async function loadAllTablesMetaData(
const dbArg = getDatabaseArg(input)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
// MySQL needs the database name for metadata queries
let databaseName: string | undefined
if (input.type === 'database' && input.resourceType === 'mysql') {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: input.resourcePath
})) as any
databaseName = resourceObj?.database
}
const language = getLanguageByResourceType(dbType)
const content = makeMetadataMarker(
'LOAD_TABLE_METADATA',
{ table: undefined, databaseName },
ducklake
)
// MySQL db name is resolved server-side via `DATABASE()` (see loadTableMetaData);
// no client-side resource-value read.
const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table: undefined }, ducklake)
let result = (await runScriptAndPollResult({
workspace,
@@ -259,7 +239,11 @@ export async function getDbSchemas(
const dbSchema = {
lang: resourceTypeToLang(resourceType) as SQLSchema['lang'],
schema,
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo,
// MySQL introspection selects `DATABASE() AS default_db_name`; carry it
// so the table picker can tell the default db apart from other visible
// schemas. Other dbs don't return it (stays undefined).
defaultDb: Array.isArray(result) ? (result[0] as any)?.default_db_name : undefined
}
return { ...dbSchema, stringified: stringifySchema(dbSchema) }
} else {
@@ -283,9 +267,7 @@ export async function getDbSchemas(
export async function getTablesByResource(
schema: Partial<Record<string, DBSchema>>,
dbType: DbType | undefined,
dbPath: string,
workspace: string
dbType: DbType | undefined
): Promise<string[]> {
const s = Object.values(schema)?.[0]
switch (dbType) {
@@ -301,14 +283,15 @@ export async function getTablesByResource(
return paths
}
case 'mysql': {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: dbPath.split('$res:')[1]
})) as any
// MySQL introspection lists DATABASE() plus any other visible non-system
// schemas. Show the default db's tables unprefixed and the rest as
// `db.table` — matching the pre-removal behavior (which matched the
// resource's `database`); `defaultDb` is the connection's DATABASE().
const defaultDb = s && 'defaultDb' in s ? s.defaultDb : undefined
const paths: string[] = []
for (const key in s?.schema) {
for (const subKey in s.schema[key]) {
if (key === resourceObj?.database) {
if (key === defaultDb) {
paths.push(`${subKey}`)
} else {
paths.push(`${key}.${subKey}`)
@@ -3,7 +3,7 @@
import type { AppInput } from '../../inputType'
import type { Output } from '../../rx'
import type { AppViewerContext, ListContext } from '../../types'
import { isScriptByNameDefined, isScriptByPathDefined } from '../../utils'
import { appNavigateSameWindow, isScriptByNameDefined, isScriptByPathDefined } from '../../utils'
import NonRunnableComponent from './NonRunnableComponent.svelte'
import RunnableComponent from './RunnableComponent.svelte'
import { sendUserToast } from '$lib/toast'
@@ -261,7 +261,9 @@
if (newTab) {
window.open(gotoUrl, '_blank')
} else {
window.location.href = gotoUrl
// Top-level load; inside the opaque viewer iframe this targets the
// top page (pre-sandbox behavior) instead of the cookieless frame.
appNavigateSameWindow(gotoUrl)
}
break
@@ -2,6 +2,8 @@ import type { World } from '../../rx'
import { sendUserToast } from '$lib/toast'
import { waitJob } from '$lib/components/waitJob'
import { base } from '$lib/base'
import { appNavigateSameWindow } from '../../utils'
import { OpenAPI } from '$lib/gen/core/OpenAPI'
export function computeGlobalContext(
world: World | undefined,
@@ -200,7 +202,9 @@ export async function eval_like(
}
window.open(x, '_blank')
} else {
window.location.href = x
// Top-level load; inside the opaque viewer iframe this targets the
// top page (pre-sandbox behavior) instead of the cookieless frame.
appNavigateSameWindow(x)
}
},
(id, index) => {
@@ -292,10 +296,30 @@ export async function eval_like(
if (typeof input === 'object' && input.s3) {
const workspaceId = ((context ?? {}) as any).ctx?.workspace
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent(
input?.s3 ?? ''
)}${input?.storage ? `&storage=${input.storage}` : ''}`
downloadFile(s3href, filename || input.s3)
const appPath = ((context ?? {}) as any).ctx?.app_path
let inSandbox = false
try {
inSandbox =
window.parent !== window &&
new URLSearchParams(window.location.search).get('wm_embed') === '1'
} catch (_) {}
if (inSandbox && appPath && typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
// Sandboxed viewer: the opaque iframe carries no cookie, so the
// cookie-authed job_helpers download fails. Route through the
// app-policy-confined apps_u endpoint with the embed token in the
// query (like the image/file components), scoped to this app's path.
const params = new URLSearchParams()
params.append('s3', input.s3 ?? '')
if (input.storage) params.append('storage', input.storage)
params.append('token', OpenAPI.TOKEN)
const s3href = `${base}/api/w/${workspaceId}/apps_u/download_s3_file/${appPath}?${params.toString()}`
downloadFile(s3href, filename || input.s3)
} else {
const s3href = `${base}/api/w/${workspaceId}/job_helpers/download_s3_file?file_key=${encodeURIComponent(
input?.s3 ?? ''
)}${input?.storage ? `&storage=${input.storage}` : ''}`
downloadFile(s3href, filename || input.s3)
}
} else if (typeof input === 'string') {
if (input.startsWith('data:')) {
downloadFile(input, filename)
@@ -395,14 +395,16 @@
}
}
async function setPublishState() {
async function setPublishState(message?: string) {
policy = await updatePolicy($app, policy)
await AppService.updateApp({
workspace: $workspaceStore!,
path: $appPath,
requestBody: { policy }
})
if (policy.execution_mode == 'anonymous') {
if (message) {
sendUserToast(message)
} else if (policy.execution_mode == 'anonymous') {
sendUserToast('App require no login to be accessed')
} else {
sendUserToast('App require login and read-access')
@@ -1,5 +1,6 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Loader2 } from 'lucide-svelte'
@@ -41,7 +42,7 @@
newApp = false
}: {
policy: any
setPublishState: () => void
setPublishState: (message?: string) => void
appPath: string
customPath: string | undefined
onLatest: boolean
@@ -274,6 +275,40 @@
<div class="mt-10"></div>
<div class="flex items-center gap-2">
<h2>Sandbox isolation</h2>
<Badge color="yellow">Alpha</Badge>
</div>
<div class="my-6">
<Toggle
options={{ right: "Isolate the app from the viewer's browser session" }}
checked={policy.sandbox == true}
on:change={(e) => {
policy.sandbox = e.detail || undefined
setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled')
}}
disabled={!savedApp}
/>
<div class="text-xs text-secondary mt-1">
Controls what the app's browser-side code can reach in each viewer's browser — distinct from the
on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code
uses the viewer's own session; enable it to confine the app to a narrowly-scoped token instead,
on every surface (public URL and in-workspace). Leave it off if the app needs full browser
features (IndexedDB, third-party auth/SDKs, OAuth redirects).
</div>
{#if !savedApp}
<div class="text-xs text-tertiary mt-1">Save the app once to change this setting.</div>
{/if}
{#if policy.sandbox == true}
<div class="mt-2">
<Alert type="warning" title="Alpha feature" size="xs">
Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm
it still works, and report any broken behavior.
</Alert>
</div>
{/if}
</div>
{#if !hideSecretUrl}
<h2>Public URL</h2>
@@ -78,7 +78,8 @@
workspace: untrack(() => workspace),
mode: 'viewer',
summary: untrack(() => summary),
author: untrack(() => policy).on_behalf_of_email
author: untrack(() => policy).on_behalf_of_email,
app_path: untrack(() => appPath)
}
function resizeWindow() {
@@ -0,0 +1,146 @@
<script lang="ts">
/*
* WIN-2006: shared in-workspace app viewer (low-code AND raw). Renders the app
* through the same PublicAppFrame -> PublicApp machinery the public viewer uses,
* so the sandbox behavior is identical on every page. PublicAppFrame picks the
* rendering: an opaque /app_embed iframe for sandboxed low-code, or inline (with
* the bundle isolated in RawAppPreview's own opaque iframe) for raw and for
* unsandboxed apps. When the publisher opts an app into sandbox isolation, its
* untrusted markup/JS must not run with the member's full session — hence the
* scoped embed token / opaque isolation.
*/
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { Pen } from 'lucide-svelte'
import { page } from '$app/state'
let {
workspace,
path,
editHref
}: {
workspace: string
path: string
/** Where the Edit button points (low-code vs raw editor). */
editHref: string
} = $props()
let app: any = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let canWriteApp = $state(false)
let refresh: (() => void) | undefined
// The opaque iframe loads the dedicated cookieless, chrome-less viewer route.
// The page's query/hash are forwarded so the app sees the same `ctx.query` /
// `ctx.hash` as the pre-sandbox viewer did. Captured ONCE (not reactively):
// the embedder later mirrors the app's own hash/query changes back onto this
// page's URL (wm_embed_hash relay), and re-deriving the iframe src from them
// would reload the app on its every navigation.
const initialSearchHash = page.url.search + page.url.hash
let viewerUrl = $derived(`${base}/app_embed/${workspace}/${path}${initialSearchHash}`)
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
// Embedder side: mint a scoped embed token (by path) from the member's session.
async function fetchEmbedToken(): Promise<{ token?: string }> {
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}`, {
headers
})
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
}
// Viewer side — used for the inline renderings (raw, and unsandboxed low-code);
// the sandboxed low-code case loads inside the opaque /app_embed iframe instead.
// getAppByPath returns bundle_secret + runnables for raw apps, which
// PublicApp -> RawAppPreview needs.
async function loadApp() {
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
const loaded: any = await AppService.getAppByPath({ workspace, path })
// Raw apps need the bundle secret to load their bundle. getAppByPath
// doesn't compute it (unlike the public handlers), so fetch it here — the
// same call the previous raw viewer used — and hand it to PublicApp ->
// RawAppPreview via bundle_secret.
if (loaded?.raw_app && !loaded.bundle_secret) {
try {
loaded.bundle_secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace,
path
})
} catch (e) {
console.error('Failed to load raw app bundle secret', e)
}
}
app = loaded
noPermission = false
notExists = false
} catch (e: any) {
if (e.status == 401) refresh?.()
else if (e.status == 403) noPermission = true
else notExists = true
}
}
// Edit button: determine write access on this real-origin page (cookie).
async function loadPerms() {
try {
const lite: any = await AppService.getAppLiteByPath({ workspace, path })
canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore)
} catch (_) {
canWriteApp = false
}
}
$effect(() => {
if (workspace && path) loadPerms()
})
</script>
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
{#if canWriteApp && !hideEditBtn}
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
<Button size="sm" startIcon={{ icon: Pen }} variant="subtle" href={editHref}>Edit</Button>
</div>
{/if}
@@ -7,8 +7,13 @@
import { isCloudHosted } from '$lib/cloud'
import { Alert, Skeleton } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import { onMount, setContext } from 'svelte'
import { IS_APP_PUBLIC_CONTEXT_KEY, type EditorBreakpoint } from '../types'
import { getContext, onMount, setContext } from 'svelte'
import {
EMBED_NAV_CONTEXT_KEY,
IS_APP_PUBLIC_CONTEXT_KEY,
type EditorBreakpoint,
type EmbedNav
} from '../types'
import { UserService, type AppWithLastVersion, type GlobalWhoamiResponse } from '$lib/gen'
import { urlParamsToObject } from '$lib/utils'
import { goto } from '$app/navigation'
@@ -24,7 +29,9 @@
jwtError,
onLoginSuccess,
app,
workspace
workspace,
inWorkspace = false,
hideRefreshBar = false
}: {
notExists: boolean
noPermission: boolean
@@ -32,12 +39,27 @@
onLoginSuccess: () => void
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
workspace: string | undefined
/**
* In-workspace rendering (`/apps/get`, `/app_embed`): keep exact parity
* with the pre-sandbox member viewer — no "Powered by Windmill" badge, no
* user overlay, no HTML-result approval gate, column flex wrapper.
*/
inWorkspace?: boolean
hideRefreshBar?: boolean
} = $props()
// Use workspace from props or from app.workspace_id (for custom path responses)
let effectiveWorkspace = $derived(workspace ?? app?.workspace_id)
setContext(IS_APP_PUBLIC_CONTEXT_KEY, true)
// HTML results from runnables only need viewer approval on the public
// surfaces (untrusted distribution); the in-workspace viewer never gated them.
setContext(IS_APP_PUBLIC_CONTEXT_KEY, !inWorkspace)
// WIN-2006: inside the opaque viewer iframe, navigations to other routes
// (navbar "app" items) must happen on the TOP page — the iframe is cookieless,
// so navigating it would just show a login screen. PublicAppFrame provides the
// relay; outside the opaque viewer this is undefined and goto works directly.
const embedNav = getContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY)
const breakpoint = writable<EditorBreakpoint>('lg')
@@ -71,27 +93,29 @@
})
</script>
<div
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
: ''}"
>
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
>Powered by &nbsp;<WindmillIcon />&nbsp;Windmill</a
{#if !inWorkspace}
<div
class="z-50 text-xs fixed bottom-1 right-2 {$enterpriseLicense && !isCloudHosted()
? 'transition-opacity delay-1000 duration-1000 opacity-20 hover:delay-0 hover:opacity-100'
: ''}"
>
</div>
<a href="https://windmill.dev" class="whitespace-nowrap text-primary inline-flex items-center"
>Powered by &nbsp;<WindmillIcon />&nbsp;Windmill</a
>
</div>
{#snippet userInfo(child)}
<div class="flex gap-1 items-center"><User size={14} />{child}</div>
{/snippet}
{#snippet userInfo(child)}
<div class="flex gap-1 items-center"><User size={14} />{child}</div>
{/snippet}
<div class="z-50 text-2xs text-primary absolute top-3 left-2"
>{#if $userStore}
{@render userInfo($userStore.username)}
{:else if globalUser}
{@render userInfo(globalUser.email)}
{:else}<UserRoundX size={14} />{/if}
</div>
<div class="z-50 text-2xs text-primary absolute top-3 left-2"
>{#if $userStore}
{@render userInfo($userStore.username)}
{:else if globalUser}
{@render userInfo(globalUser.email)}
{:else}<UserRoundX size={14} />{/if}
</div>
{/if}
{#if notExists}
<div class="px-4 mt-20"
@@ -132,7 +156,11 @@
{:else}
<div
class={twMerge(
'min-h-screen h-full w-full flex',
// `flex-col` matches the pre-sandbox in-workspace viewer exactly;
// the public viewer always used a plain `flex` wrapper.
inWorkspace
? 'min-h-screen h-full w-full flex flex-col'
: 'min-h-screen h-full w-full flex',
app?.value?.['css']?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
@@ -140,6 +168,7 @@
>
<AppPreview
noBackend={false}
{hideRefreshBar}
context={{
email: $userStore?.email,
name: $userStore?.name,
@@ -156,7 +185,7 @@
policy={app.policy}
isEditor={false}
replaceStateFn={(path) => goto(path)}
gotoFn={(path, opt) => goto(path, opt)}
gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))}
/>
</div>
{/if}
@@ -0,0 +1,427 @@
<script lang="ts">
/*
* WIN-2006: published apps render arbitrary user-authored markup/JS. When the
* publisher opts an app into sandbox isolation (alpha), that untrusted code (a
* malicious author, or an XSS bug in the app) must not run with the viewer's
* session. How it's contained depends on the app:
*
* - Low-code, sandboxed: rendered in an **opaque-origin** (sandboxed, no
* `allow-same-origin`) iframe and handed a **narrowly-scoped embed token** —
* never the session cookie. This component is the embedder (top window:
* authenticates the viewer, mints the token, renders the opaque iframe) and,
* inside that iframe (`wm_embed=1`), the viewer (uses the token as its only
* credential, then renders the app).
* - Raw, sandboxed: rendered directly here as a **single** opaque bundle iframe
* (the author bundle is already isolated in its own opaque iframe); no opaque
* viewer / embed token needed.
* - Unsandboxed (default): the app runs same-origin with the viewer's full
* session, the pre-isolation behavior. Rendered directly here.
*
* No `PUBLIC_APP_DOMAIN` is required: the opaque origin is, for same-origin-policy
* purposes, as foreign to the main app as a different domain. The opaque viewer's
* API calls are cross-origin and rely on `Access-Control-Allow-Origin: *` + the
* bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`.
*/
import { BROWSER } from 'esm-env'
import { OpenAPI } from '$lib/gen'
import { page } from '$app/state'
import { onDestroy, onMount, setContext, type Snippet } from 'svelte'
import { Alert, Skeleton } from '$lib/components/common'
import { base } from '$app/paths'
import { goto } from '$app/navigation'
import Login from '$lib/components/Login.svelte'
import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils'
import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types'
type EmbedToken = {
token?: string | null
raw_app?: boolean
sandbox?: boolean
app_path?: string | null
workspace_id?: string | null
}
let {
fetchEmbedToken,
onViewerReady,
viewer,
viewerUrl
}: {
/** Embedder-side: validate access + mint the scoped token. Throws with a
* `.status` of 401 (login required) or 404 (not found). */
fetchEmbedToken: () => Promise<EmbedToken>
/** Viewer-side: fired (once per received token) when the embed token is
* available, before the app renders. Use it to kick off data loading.
* `requestTokenRefresh` asks the embedder for a fresh token on a 401. */
onViewerReady?: (token: string | undefined, requestTokenRefresh: () => void) => void
/** Viewer-side: renders the actual app once the embed token is available. */
viewer: Snippet
/** Embedder-side: override the opaque iframe src (the route that renders the
* viewer). Defaults to the current route + `wm_embed=1` (public routes embed
* themselves). The in-workspace viewer sets this because its embedder route
* (`/apps/get`, auth-gated, with chrome) differs from the cookieless,
* chrome-less viewer route (`/app_embed`). */
viewerUrl?: string
} = $props()
const EMBED_PARAM = 'wm_embed'
const ORIGIN_PARAM = 'wm_embedder_origin'
const framed = BROWSER && window.parent !== window
const isViewer = BROWSER && page.url.searchParams.get(EMBED_PARAM) === '1' && framed
// ----------------------------- viewer mode -----------------------------
let viewerToken: string | undefined = $state(undefined)
let viewerReady = $state(false)
// Set when viewer mode never receives a token (see the orphan timer in onMount):
// the page carries `wm_embed` but isn't actually framed by a Windmill embedder.
let viewerOrphaned = $state(false)
const expectedEmbedderOrigin = BROWSER ? page.url.searchParams.get(ORIGIN_PARAM) : null
// Components that embed the token in a URL (images, PDFs, downloads, SSE) read
// it from the `AuthToken` context. In viewer mode that must be the embed token;
// the getter keeps it in sync once the token arrives. In direct render
// (unsandboxed / raw — the viewer snippet runs on this page, not in the opaque
// iframe) expose the page's own bearer credential when there is one: JWT public
// URLs put it in `OpenAPI.TOKEN` (set before the app loads) and have no cookie
// to fall back on. Cookie sessions have no bearer here and keep using the cookie.
setContext<{ token?: string }>('AuthToken', {
get token() {
if (isViewer) return viewerToken
return typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined
}
})
function handleViewerMessage(e: MessageEvent) {
// The embedder has a real origin, so we can validate both the source and
// the origin of messages we receive.
if (e.source !== window.parent) return
if (expectedEmbedderOrigin && e.origin !== expectedEmbedderOrigin) return
if (e.data?.type === 'wm_embed_token') {
const token = e.data.token ?? undefined
viewerToken = token
// The bearer token (when present) is the credential the app uses; no
// cookie reaches this opaque origin.
OpenAPI.TOKEN = token
viewerReady = true
viewerOrphaned = false
onViewerReady?.(token, requestTokenRefresh)
}
}
/** Passed to the viewer: when the rendered app gets a 401 (e.g. the embed
* token expired) it calls this to ask the embedder for a fresh token. */
function requestTokenRefresh() {
if (!isViewer) {
// Embedder-side direct render (raw app, or unsandboxed app): there is no
// opaque viewer to message — just re-run the access check / app load.
initEmbedder()
return
}
viewerReady = false
window.parent.postMessage({ type: 'wm_embed_unauthorized' }, expectedEmbedderOrigin ?? '*')
}
// WIN-2006: the app runs inside the (opaque) viewer iframe, so its in-app URL
// changes never reach the top address bar — breaking shareable deep links. Relay
// the hash + query up to the embedder, which mirrors them onto its own URL
// (e.g. the navbar component's same-app links set ?query and #hash). Never the
// path: the embedder keeps its own pathname, so a hostile app can't rewrite the
// address bar to an unrelated route. Transport params (wm_embed, …) are
// stripped before relaying.
let origPushState: typeof history.pushState | undefined
let origReplaceState: typeof history.replaceState | undefined
function relayHash() {
try {
const params = new URLSearchParams(window.location.search)
const reserved: string[] = []
params.forEach((_v, k) => {
if (WINDMILL_RESERVED_QUERY_PARAMS.has(k)) reserved.push(k)
})
reserved.forEach((k) => params.delete(k))
const qs = params.toString()
const search = qs ? `?${qs}` : ''
window.parent.postMessage(
{ type: 'wm_embed_hash', hash: window.location.hash, search },
expectedEmbedderOrigin ?? '*'
)
} catch (_) {}
}
// Top-level navigation relay (WIN-2006): in-app links to other apps (navbar
// "app" items) must navigate the top page — navigating inside the opaque
// iframe would load the SPA cookieless. Consumed by PublicApp via gotoFn.
setContext<EmbedNav | undefined>(
EMBED_NAV_CONTEXT_KEY,
isViewer
? {
navigateTop: (href: string) => {
try {
window.parent.postMessage(
{ type: 'wm_embed_navigate', href },
expectedEmbedderOrigin ?? '*'
)
} catch (_) {}
}
}
: undefined
)
function installHashRelay() {
origPushState = history.pushState
origReplaceState = history.replaceState
history.pushState = function (data, unused, url) {
origPushState?.call(history, data, unused, url ?? null)
relayHash()
}
history.replaceState = function (data, unused, url) {
origReplaceState?.call(history, data, unused, url ?? null)
relayHash()
}
window.addEventListener('hashchange', relayHash)
window.addEventListener('popstate', relayHash)
}
function uninstallHashRelay() {
if (origPushState) history.pushState = origPushState
if (origReplaceState) history.replaceState = origReplaceState
window.removeEventListener('hashchange', relayHash)
window.removeEventListener('popstate', relayHash)
}
// ---------------------------- embedder mode ----------------------------
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' = $state('loading')
let embedToken: string | null = $state(null)
let iframeEl: HTMLIFrameElement | undefined = $state(undefined)
// WIN-2006: publisher opted this app into sandbox isolation (alpha). When false
// (the default) the app runs same-origin with the viewer's full session — the
// pre-isolation behavior.
let sandboxed = $state(false)
// WIN-2006 Variant A: raw apps render single-iframe. The author's bundle is
// already isolated in its own opaque iframe (served + CSP-sandboxed by the
// backend), so the viewer skips the opaque-viewer indirection and the embed
// token entirely — it renders the app directly on this (real) origin and the
// bridge calls the backend with the page credential (cookie / JWT / anonymous),
// matching the logged-in raw viewer. Low-code keeps the opaque viewer + token.
let isRaw = $state(false)
// WIN-2006: the resolved app path + workspace, used together to scope this app's
// backing localStorage (below) so sandboxed apps don't share one store (even two
// apps at the same path in different workspaces).
let appPath: string | undefined = $state(undefined)
let workspaceId: string | undefined = $state(undefined)
// Same-origin (full session) execution: every app that wasn't opted into
// sandbox isolation. RawAppPreview reads this to drop the bundle's opaque
// sandbox; an unsandboxed low-code app renders directly here.
let unsandboxed = $derived(!sandboxed)
// Read by RawAppPreview (and any app component) to render same-origin (full
// access) instead of the sandboxed bundle iframe.
setContext('IS_APP_UNSANDBOXED', {
get value() {
return unsandboxed
}
})
function buildViewerUrl(): string {
// Default: embed the current route. The in-workspace viewer overrides this
// with a dedicated cookieless, chrome-less viewer route (`/app_embed`).
const url = new URL(viewerUrl ?? window.location.href, window.location.origin)
url.searchParams.set(EMBED_PARAM, '1')
url.searchParams.set(ORIGIN_PARAM, window.location.origin)
// Same origin (no separate domain); the sandbox makes it opaque.
return url.pathname + url.search + url.hash
}
async function initEmbedder() {
status = 'loading'
try {
const resp = await fetchEmbedToken()
embedToken = resp.token ?? null
sandboxed = resp.sandbox ?? false
isRaw = resp.raw_app ?? false
appPath = resp.app_path ?? undefined
workspaceId = resp.workspace_id ?? undefined
status = 'ready'
if (unsandboxed || isRaw) {
// Render the app directly on this origin: same-origin when unsandboxed
// (the default), or a single opaque bundle iframe when it's a sandboxed
// raw app.
onViewerReady?.(undefined, requestTokenRefresh)
} else {
// Sandboxed low-code: hand the scoped token to the opaque viewer iframe.
postTokenToIframe()
}
} catch (e: any) {
status = e?.status === 401 ? 'noPermission' : 'notExists'
}
}
function postTokenToIframe() {
// The iframe is an opaque origin ("null"), which cannot be named as a
// targetOrigin, so we use '*'. This only relaxes the receiver-origin
// check; the message is still delivered solely to our own iframe's
// contentWindow, whose content we control.
iframeEl?.contentWindow?.postMessage({ type: 'wm_embed_token', token: embedToken }, '*')
}
// Persistence authority (WIN-2006): opaque app frames (the viewer SPA) have no
// real Web Storage, so the embedder — on the real origin — backs their
// `localStorage` here. The store is scoped PER APP (keyed by workspace + app path)
// so one sandboxed app cannot read or clobber another's storage. Updated with
// per-key ops so concurrent frames of the same app merge instead of clobbering.
function lsKey(): string {
return `wm_apps_localstorage:${workspaceId ?? ''}:${appPath ?? ''}`
}
function readSharedLs(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(lsKey()) || '{}')
} catch (_) {
return {}
}
}
function applyLsOp(d: any) {
const s = readSharedLs()
if (d.op === 'set') s[d.key] = String(d.value)
else if (d.op === 'remove') delete s[d.key]
else if (d.op === 'clear') for (const k in s) delete s[k]
try {
localStorage.setItem(lsKey(), JSON.stringify(s))
} catch (_) {}
}
function handleEmbedderMessage(e: MessageEvent) {
// The viewer is opaque-origin (e.origin === 'null'), so we authenticate
// the message by source identity only. Storage messages come from the opaque
// low-code viewer SPA's shim and are backed in this app's per-app store.
if (e.source !== iframeEl?.contentWindow) return
if (e.data?.type === 'wm_embed_ready') {
postTokenToIframe()
} else if (e.data?.type === 'wm_embed_unauthorized') {
initEmbedder()
} else if (e.data?.type === 'wm_ls_req') {
iframeEl?.contentWindow?.postMessage({ type: 'wm_ls_hydrate', data: readSharedLs() }, '*')
} else if (e.data?.type === 'wm_ls_op') {
applyLsOp(e.data)
} else if (e.data?.type === 'wm_embed_hash') {
// Mirror the viewer's in-app hash + query onto our own URL (shareable
// deep links). Keep our pathname (an app can't rewrite the address bar
// to another route) and our own transport params (e.g. wm_coep).
const hash = typeof e.data.hash === 'string' ? e.data.hash : ''
const relayed = new URLSearchParams(
typeof e.data.search === 'string' ? e.data.search : window.location.search
)
for (const k of WINDMILL_RESERVED_QUERY_PARAMS) {
relayed.delete(k)
const own = new URLSearchParams(window.location.search).get(k)
if (own !== null) relayed.set(k, own)
}
const qs = relayed.toString()
const search = qs ? `?${qs}` : ''
if (window.location.hash !== hash || window.location.search !== search) {
history.replaceState(null, '', window.location.pathname + search + hash)
}
} else if (e.data?.type === 'wm_embed_navigate') {
// App-initiated same-window navigation (navbar app items, frontend-script
// `goto`, button `gotoUrl`): this page navigates itself, exactly like when
// the app ran on it pre-sandbox. Same-origin paths use SPA navigation;
// full http(s) URLs do a real load (pre-sandbox apps could already do
// this via window.location, so it grants nothing new). Everything else —
// javascript:, data:, protocol-relative `//host` — is rejected.
const href = typeof e.data.href === 'string' ? e.data.href : ''
if (href.startsWith('/') && !href.startsWith('//')) {
goto(href)
} else if (/^https?:\/\//i.test(href)) {
window.location.href = href
}
}
}
onMount(() => {
if (isViewer) {
window.addEventListener('message', handleViewerMessage)
installHashRelay()
// Announce readiness so the embedder sends us the token.
window.parent.postMessage({ type: 'wm_embed_ready' }, expectedEmbedderOrigin ?? '*')
// This page is only ever loaded as the embedder's opaque iframe, which
// replies with the token within milliseconds. If none arrives, it was
// opened with `wm_embed` outside a Windmill embedder (e.g. its iframe src
// embedded directly in a third-party page); show a diagnostic instead of an
// indefinite skeleton.
const orphanTimer = setTimeout(() => {
if (!viewerReady) viewerOrphaned = true
}, 3000)
return () => clearTimeout(orphanTimer)
} else {
window.addEventListener('message', handleEmbedderMessage)
initEmbedder()
}
})
onDestroy(() => {
if (!BROWSER) return
window.removeEventListener('message', handleViewerMessage)
window.removeEventListener('message', handleEmbedderMessage)
if (isViewer) uninstallHashRelay()
})
</script>
{#if isViewer}
{#if viewerReady}
{@render viewer()}
{:else if viewerOrphaned}
<div class="px-4 mt-20 max-w-xl mx-auto">
<Alert type="info" title="Open this app from Windmill">
This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own
page, use the app's public URL without the <code>wm_embed</code> parameter.
</Alert>
</div>
{:else}
<Skeleton layout={[[4], 0.5, [50]]} />
{/if}
{:else if status === 'loading'}
<Skeleton layout={[[4], 0.5, [50]]} />
{:else if status === 'notExists'}
<div class="px-4 mt-20">
<Alert type="error" title="Not found">
There was an error loading the app, is the url correct?
<a href={base}>Go to Windmill</a>
</Alert>
</div>
{:else if status === 'noPermission'}
<!-- Login happens here, on the embedder (main) window, so the session cookie
is set on the main origin only and never reaches the opaque iframe. -->
<div class="px-4 mt-20 w-full text-center font-bold text-xl">This app requires read access</div>
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
<Login
onLoginSuccess={() => initEmbedder()}
popup
rd={page.url.pathname + page.url.search + page.url.hash}
/>
</div>
{:else if unsandboxed}
<!-- Same-origin (full session): the app was not opted into sandbox isolation
(the default). Rendered directly here; RawAppPreview reads
IS_APP_UNSANDBOXED to drop the bundle's opaque sandbox. -->
{@render viewer()}
{:else if isRaw}
<!-- Variant A: sandboxed raw app rendered directly on the real origin. The
untrusted author bundle stays isolated in its own opaque iframe (inside
RawAppPreview); no opaque viewer and no embed token are needed. -->
{@render viewer()}
{:else}
<!-- referrerpolicy: the embedder page URL can carry a viewer credential (the
JWT path segment of share links); without this, the same-origin iframe
navigation would expose it to app-authored code via document.referrer. -->
<iframe
bind:this={iframeEl}
src={buildViewerUrl()}
title="App"
class="w-full h-screen border-0 block"
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation"
allow="clipboard-read; clipboard-write; fullscreen"
referrerpolicy="no-referrer"
></iframe>
{/if}
@@ -180,12 +180,13 @@ export async function updatePolicy(app: App, currentPolicy: Policy | undefined):
})
.filter(Boolean) as { s3_path: string; storage?: string | undefined }[]
return {
const next = {
...(currentPolicy ?? {}),
allowed_s3_keys: s3FileKeys,
s3_inputs,
triggerables_v2: ntriggerables
}
return next
}
export async function processRunnable(
@@ -370,6 +370,12 @@ export type EditorBreakpoint = 'sm' | 'lg'
export const IS_APP_PUBLIC_CONTEXT_KEY = 'isAppPublicContext' as const
// Set by PublicAppFrame in opaque-viewer mode (WIN-2006). Lets the app relay
// top-level navigations (e.g. navbar links to another app) to the embedder,
// since navigating inside the opaque iframe would load the SPA cookieless.
export const EMBED_NAV_CONTEXT_KEY = 'appEmbedNav' as const
export type EmbedNav = { navigateTop: (href: string) => void }
type ComponentID = string
export type ContextPanelContext = {
+25
View File
@@ -20,6 +20,31 @@ import type {
} from './types'
import { allItems, BG_PREFIX } from './editor/appUtilsCore'
/**
* Same-window navigation for app code (frontend-script `goto`, button
* `onSuccess: gotoUrl`). Inside the opaque viewer iframe (WIN-2006,
* `wm_embed=1`), navigating the current window would load the target inside
* the cookieless frame so the navigation is relayed to the embedder page,
* which navigates itself (`wm_embed_navigate` in PublicAppFrame). That matches
* the pre-sandbox behavior exactly: the app used to run ON the embedder page,
* including when that page is itself inside a third-party iframe (where the
* embedder not the third party's top was what `window.location` changed).
* Outside the opaque viewer it keeps navigating the current window as before.
*/
export function appNavigateSameWindow(url: string) {
try {
const params = new URLSearchParams(window.location.search)
if (window.parent !== window && params.get('wm_embed') === '1') {
window.parent.postMessage(
{ type: 'wm_embed_navigate', href: url },
params.get('wm_embedder_origin') ?? '*'
)
return
}
} catch (_) {}
window.location.href = url
}
// `migrateApp` moved to its own light module so non-editor callers can reuse it
// without pulling the whole `apps/utils` graph; re-exported here for existing
// `from '../utils'` importers.
@@ -33,7 +33,7 @@
</script>
<Row
href="{base}/apps/get_raw/{app.version}/{app.path}"
href="{base}/apps_raw/get/{app.path}"
kind="raw_app"
{marked}
path={app.path}
@@ -15,6 +15,16 @@
jobsById?: Record<string, JobById>
editor: boolean
workspace: string
/**
* Restrict waitJob/getJob/streamJob to job ids launched by this app
* instance (WIN-2006): a SANDBOXED bundle must not read arbitrary
* workspace jobs through the credentialed bridge. Off for unsandboxed
* renders (the default, and editor preview) — there the bundle holds
* the same credential as the bridge, so gating adds nothing and would
* only break unsandboxed apps that poll persisted or runnable-returned
* job ids.
*/
gateJobIds?: boolean
}
let {
@@ -24,10 +34,18 @@
jobs = $bindable([]),
jobsById = $bindable({}),
editor,
workspace
workspace,
gateJobIds = true
}: Props = $props()
// Job ids launched by this app instance — see `gateJobIds`.
const launchedJobs = new Set<string>()
let listener = async (event) => {
// Only accept messages from the bundle iframe (opaque origin) so other
// frames/extensions can't drive the runnable bridge (WIN-2006). Reject
// unconditionally until the iframe is bound — never process a message from
// an unknown source.
if (!iframe || event.source !== iframe.contentWindow) return
const data = event.data
@@ -115,6 +133,7 @@
},
undefined
)
launchedJobs.add(uuid)
let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid }
if (event.data.type == 'backendAsync') {
let result = uuid
@@ -134,14 +153,29 @@
console.error('No runnable found for', runnable_id)
}
} else if (event.data.type == 'waitJob') {
if (gateJobIds && !launchedJobs.has(data.jobId)) {
respond({ result: { message: 'Unknown job' }, error: true })
return
}
await respondWithResult(data.jobId)
} else if (event.data.type == 'getJob') {
if (gateJobIds && !launchedJobs.has(data.jobId)) {
respond({ result: { message: 'Unknown job' }, error: true })
return
}
const job = await JobService.getJob({ workspace, id: data.jobId })
respond({ result: job })
} else if (event.data.type == 'streamJob') {
// Stream job results using SSE
const jobId = data.jobId
const reqId = data.reqId
if (gateJobIds && !launchedJobs.has(jobId)) {
iframe?.contentWindow?.postMessage(
{ type: 'streamJobRes', reqId, error: true, result: { message: 'Unknown job' } },
'*'
)
return
}
const params = new URLSearchParams()
params.set('fast', 'true')
params.set('only_result', 'true')
@@ -1496,6 +1496,7 @@
bind:jobsById
{runnables}
{path}
gateJobIds={false}
/>
<div class="max-h-screen overflow-hidden h-screen min-h-0 flex flex-col">
<RawAppEditorHeader
@@ -473,14 +473,16 @@
onDeploy?.({ path: npath })
}
async function setPublishState() {
async function setPublishState(message?: string) {
await computeTriggerables()
await AppService.updateApp({
workspace: $workspaceStore!,
path: appPath,
requestBody: { policy }
})
if (policy.execution_mode == 'anonymous') {
if (message) {
sendUserToast(message)
} else if (policy.execution_mode == 'anonymous') {
sendUserToast('App require no login to be accessed')
} else {
sendUserToast('App require login and read-access')
@@ -2,8 +2,8 @@
import { type UserExt } from '$lib/stores'
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
import type { Runnable } from './rawAppPolicy'
import { htmlContent } from './utils'
import { onMount, untrack } from 'svelte'
import { getContext, onMount, untrack } from 'svelte'
import { unsandboxedRawAppHtml } from './utils'
interface Props {
workspace: string
@@ -17,43 +17,190 @@
let iframe = $state() as HTMLIFrameElement | undefined
// Get initial hash from parent URL to pass to iframe
let initialHash = $state('')
// Get initial hash from parent URL to pass to the iframe
let initialHash = ''
onMount(() => {
initialHash = window.location.hash || ''
// WIN-2006: unless the publisher opted into sandbox isolation, run the bundle
// same-origin with full access (the default); otherwise the opaque-origin sandbox.
const unsandboxedCtx = getContext<{ value: boolean }>('IS_APP_UNSANDBOXED')
let unsandboxed = $derived(unsandboxedCtx?.value ?? false)
// Unsandboxed (the default) must match the pre-isolation viewer exactly: NO
// sandbox attribute (a same-origin blob with full session — an attribute would
// only break leftover features like unsandboxed popups for OAuth flows, while
// adding no isolation). The sandboxed path keeps the restrictive attribute; the
// wrapper document's `CSP: sandbox` response header enforces the opaque origin
// regardless.
let sandboxAttr = $derived(
unsandboxed
? undefined
: 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-top-navigation'
)
// WIN-2006: source of the bundle iframe.
// - DEFAULT (isolated): a real API URL serving a sandboxed, opaque-origin
// document (`CSP: sandbox` response header + the iframe sandbox attribute),
// so a malicious bundle can never reach the authenticated Windmill origin
// (no cookie, no window.parent, no token). Root-relative so it resolves
// against the real host even when this component itself runs inside an opaque
// viewer (where `location.origin` is "null"). Context is handed over via
// postMessage — never baked into the document, never a credential.
// - UNSANDBOXED (the default — publisher did not opt into isolation): a
// client-built blob: wrapper (same-origin with the SPA) loaded with `allow-same-origin`,
// so relative `fetch('/api/...')` and the session cookie work. The backend
// `.html` is ALWAYS sandboxed, so we must build the same-origin wrapper here
// rather than relax a real-origin endpoint a victim could be linked to.
let iframeSrc = $derived.by(() => {
if (!secret || typeof window === 'undefined') return undefined
if (unsandboxed) {
// untrack(user) so userStore refreshes don't regenerate the blob URL and
// reload the iframe (losing state); ctx is only needed for initial render.
// Always pass the wrapper object — pre-sandbox bundles rely on
// `window.ctx.workspace` even for anonymous viewers (ctx.ctx undefined).
const u = untrack(() => user)
const html = unsandboxedRawAppHtml(
workspace,
secret,
{ ctx: u, workspace },
window.location.origin,
window.location.hash || ''
)
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
}
// `wm_coep` (embed-in-cross-origin-isolated-page opt-in) must be propagated
// to the wrapper document: under a COEP `require-corp` embedder, a nested
// document is only allowed to load if it asserts COEP itself, so the
// backend adds the header when the flag is present.
const coep = new URLSearchParams(window.location.search).has('wm_coep') ? '?wm_coep=1' : ''
return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html${coep}`
})
// Use blob URL instead of srcDoc to give the iframe a proper origin.
// srcDoc iframes have "null" origin which breaks URL constructor in routers.
// untrack(user) so that userStore refreshes don't regenerate the blob URL
// and cause the iframe to fully reload (losing all state).
// The user context is only needed for initial render.
let blobUrl = $derived.by(() => {
if (!secret) return undefined
const u = untrack(() => user)
const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const html = htmlContent(workspace, secret, { ctx: u, workspace }, baseUrl, initialHash)
const blob = new Blob([html], { type: 'text/html' })
return URL.createObjectURL(blob)
})
// Cleanup blob URL when it changes or component unmounts
// Revoke blob: URLs (unsandboxed path) when they change or on unmount.
$effect(() => {
const url = blobUrl
const url = iframeSrc
return () => {
if (url) URL.revokeObjectURL(url)
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url)
}
})
// Persistence for the bundle's (opaque-origin) localStorage, backed by a store
// scoped PER APP (keyed by workspace + app path) so one sandboxed app can't read
// or clobber another's (even two apps at the same path in different workspaces). On a real origin (workspace viewer, public page — even when
// that page sits inside someone else's iframe) it reads/writes real localStorage
// directly. Only inside an opaque frame (the Windmill embed viewer), where Web
// Storage throws, does it relay per-key ops up to the embedder, the persistence
// authority. `framed` therefore probes storage rather than just `window.parent`:
// an externally-embedded public page is framed too, but its parent is not the
// Windmill embedder and would never answer the relay (leaving the bundle without
// ctx). The snapshot is handed to the bundle before it evaluates so its
// localStorage is hydrated synchronously.
const SHARED_LS_KEY = `wm_apps_localstorage:${workspace}:${path}`
function storageAccessible(): boolean {
try {
localStorage.getItem(SHARED_LS_KEY)
return true
} catch (_) {
return false
}
}
const framed = typeof window !== 'undefined' && window.parent !== window && !storageAccessible()
let bundleStorage: Record<string, string> | undefined = undefined
let pendingReady = false
function readDirect(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(SHARED_LS_KEY) || '{}')
} catch (_) {
return {}
}
}
function applyDirectOp(d: any) {
try {
const s = readDirect()
if (d.op === 'set') s[d.key] = String(d.value)
else if (d.op === 'remove') delete s[d.key]
else if (d.op === 'clear') for (const k in s) delete s[k]
localStorage.setItem(SHARED_LS_KEY, JSON.stringify(s))
} catch (_) {}
}
function respondCtx() {
iframe?.contentWindow?.postMessage(
{
type: 'windmill:ctx',
// Same shape as the unsandboxed wrapper: always the object, so
// `window.ctx.workspace` works for anonymous viewers too.
ctx: { ctx: user, workspace },
initialHash,
storage: { local: bundleStorage ?? {}, session: {} }
},
'*'
)
}
onMount(() => {
initialHash = window.location.hash || ''
if (framed) {
// Pre-fetch the shared store from the embedder.
try {
window.parent.postMessage({ type: 'wm_ls_req' }, '*')
} catch (_) {}
// If the parent never answers (it isn't the Windmill embedder, e.g. an
// opaque context created by a third party), don't hold the bundle's ctx
// hostage: proceed with empty storage. Must beat the backend wrapper's
// own 1.5s no-ctx fallback.
const fallback = setTimeout(() => {
if (bundleStorage === undefined) {
bundleStorage = {}
if (pendingReady) {
pendingReady = false
respondCtx()
}
}
}, 750)
return () => clearTimeout(fallback)
}
})
// Listen for hash changes from iframe and update parent URL
$effect(() => {
function handleMessage(event: MessageEvent) {
console.log('[Parent] Received message:', event.data)
if (event.data?.type === 'windmill:hashchange') {
const newHash = event.data.hash || ''
console.log('[Parent] Updating hash to:', newHash)
// Update parent URL without triggering navigation
const data = event.data
// Shared-store hydration from the embedder (public mode only).
if (framed && event.source === window.parent && data?.type === 'wm_ls_hydrate') {
bundleStorage = data.data || {}
if (pendingReady) {
pendingReady = false
respondCtx()
}
return
}
// Everything else must come from the bundle iframe.
if (event.source !== iframe?.contentWindow) return
if (data?.type === 'windmill:ready') {
// Hand the bundle its context + shared storage before it evaluates.
if (!framed) {
bundleStorage = readDirect()
respondCtx()
} else if (bundleStorage !== undefined) {
respondCtx()
} else {
pendingReady = true
}
} else if (data?.type === 'wm_ls_op') {
// The bundle mutated localStorage — apply it to the shared store.
if (!framed) {
applyDirectOp(data)
} else {
try {
window.parent.postMessage(
{ type: 'wm_ls_op', op: data.op, key: data.key, value: data.value },
'*'
)
} catch (_) {}
}
} else if (data?.type === 'windmill:hashchange') {
// Keep the parent URL hash in sync for shareable URLs.
const newHash = data.hash || ''
if (window.location.hash !== newHash) {
history.replaceState(null, '', newHash || window.location.pathname)
}
@@ -65,13 +212,29 @@
})
</script>
<RawAppBackgroundRunner {workspace} editor={false} {iframe} {runnables} {path} />
<RawAppBackgroundRunner
{workspace}
editor={false}
{iframe}
{runnables}
{path}
gateJobIds={!unsandboxed}
/>
{#if blobUrl}
{#if iframeSrc}
<!-- `unsandboxed` (the default — publisher did not opt into isolation) adds
allow-same-origin and loads a same-origin blob: wrapper, so the bundle runs
with full access. The sandboxed path loads the always-CSP-sandboxed backend
wrapper, which stays opaque even on direct navigation. -->
<!-- referrerpolicy (sandboxed only, for exact legacy parity): the hosting page
URL can carry a viewer credential (the JWT path segment of share links);
without this, the bundle document would see it via document.referrer. -->
<iframe
bind:this={iframe}
title="raw-app"
src={blobUrl}
src={iframeSrc}
sandbox={sandboxAttr}
referrerpolicy={unsandboxed ? undefined : 'no-referrer'}
class="w-full h-full min-h-screen bg-white border-none"
></iframe>
{/if}
@@ -19,10 +19,11 @@ export async function updateRawAppPolicy(
)
).filter((entry): entry is [string, TriggerableV2] => entry != null)
const triggerables_v2 = Object.fromEntries(entries)
return {
const next: Policy = {
...currentPolicy,
triggerables_v2
}
return next
}
type RunnableWithInlineScript = RunnableWithFields & {
+31 -46
View File
@@ -141,65 +141,50 @@ export function formatAppRunsForChat(runs: RawAppRunSummary[]): string {
return JSON.stringify(runs, null, 2)
}
export function htmlContent(
// The sandboxed (isolated) raw-app wrapper is generated server-side and served as
// a sandboxed, opaque-origin document (see `get_raw_app_data` in the backend
// `apps.rs`, WIN-2006) — a blob: URL cannot carry the `CSP: sandbox` response
// header that enforces isolation, so the wrapper must come from the backend.
//
// The function below is used ONLY for the unsandboxed path (the default — the
// publisher did not opt into sandbox isolation). It is loaded as a blob: URL —
// same-origin with the SPA — so, with `allow-same-origin`, the bundle runs with
// the viewer's full session. Crucially this is an in-memory blob, not a
// real-origin endpoint, so it is not a URL an attacker can navigate a logged-in
// victim to in order to gain isolation-bypassing access — the backend `.html`
// document stays sandboxed whenever the publisher did opt in.
export function unsandboxedRawAppHtml(
workspace: string,
secret: string | undefined,
secret: string,
ctx: any,
baseUrl: string = '',
initialHash: string = ''
baseUrl: string,
initialHash: string
) {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>App Preview</title>
<title>App</title>
<link rel="stylesheet" href="${baseUrl}/api/w/${workspace}/apps_u/get_data/v/${secret}.css" />
<script>
window.ctx = ${ctx ? JSON.stringify(ctx) : 'undefined'};
// Sync hash with parent window for shareable URLs
(function() {
// Set initial hash from parent URL
(function () {
// Keep the parent URL hash in sync for shareable URLs.
function notifyParent() {
try {
if (window.parent !== window) {
window.parent.postMessage({ type: 'windmill:hashchange', hash: window.location.hash }, '*');
}
} catch (_) {}
}
var initialHash = ${JSON.stringify(initialHash)};
if (initialHash && initialHash !== '#' && !window.location.hash) {
history.replaceState(null, '', initialHash);
try { history.replaceState(null, '', initialHash); } catch (_) {}
}
// Notify parent when hash changes
function notifyParent() {
var hash = window.location.hash;
console.log('[HashSync] notifyParent called, hash:', hash);
if (window.parent !== window) {
window.parent.postMessage({
type: 'windmill:hashchange',
hash: hash
}, '*');
}
}
// Listen for hash changes
window.addEventListener('hashchange', function() {
console.log('[HashSync] hashchange event');
notifyParent();
});
// Also notify on pushState/replaceState
var originalPushState = history.pushState;
var originalReplaceState = history.replaceState;
history.pushState = function() {
console.log('[HashSync] pushState called with:', arguments[2]);
originalPushState.apply(this, arguments);
notifyParent();
};
history.replaceState = function() {
console.log('[HashSync] replaceState called with:', arguments[2]);
originalReplaceState.apply(this, arguments);
notifyParent();
};
// Notify parent of initial hash after load
window.addEventListener('hashchange', notifyParent);
var _ps = history.pushState, _rs = history.replaceState;
history.pushState = function () { _ps.apply(this, arguments); notifyParent(); };
history.replaceState = function () { _rs.apply(this, arguments); notifyParent(); };
setTimeout(notifyParent, 0);
})();
</script>
+4
View File
@@ -186,6 +186,10 @@ export interface SQLSchema {
schema: SQLBaseSchema
publicOnly: boolean | undefined
stringified: string
/** MySQL only: the connection's default database (`DATABASE()`), surfaced by the
* introspection script. Lets the table picker render the default db's tables
* unprefixed even when the connection can also see other (non-system) schemas. */
defaultDb?: string
}
export interface GraphqlSchema {
+4 -2
View File
@@ -1190,8 +1190,10 @@ export function isCodeInjection(expr: string | undefined): boolean {
// app logic via the `query` context. Only params we actually own are listed
// here — the `wm_` prefix is a naming convention, not a reserved namespace, so
// we don't strip it wholesale (that would break apps reading their own `wm_*`
// params). `wm_coep` is a transport flag for cross-origin isolation headers.
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep'])
// params). `wm_coep` is a transport flag for cross-origin isolation headers;
// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params
// (see PublicAppFrame).
export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin'])
export function urlParamsToObject(
params: URLSearchParams,
@@ -164,8 +164,9 @@
const toPath = navigation.to?.url.pathname
if (toPath && (toPath.startsWith('/apps_raw/add') || toPath.startsWith('/apps_raw/edit'))) {
const currentPath = navigation.from?.url.pathname
// Reload if we're not on an apps_raw path, or if we're on /apps/get_raw/ (viewing a raw app)
// The /apps/get_raw/ path doesn't have cross-origin isolation headers, so we need to reload
// Reload if we're not on an apps_raw path, or if we're on the raw app viewer
// (/apps_raw/get/): the viewer doesn't have cross-origin isolation headers, so
// we need a full reload to fetch them for the editor.
if (!currentPath?.startsWith('/apps_raw/') || currentPath?.startsWith('/apps_raw/get/')) {
navigation.cancel()
window.location.href = navigation.to!.url.href
@@ -1,91 +1,30 @@
<script lang="ts">
import { goto } from '$app/navigation'
/*
* WIN-2006: in-workspace low-code app viewer. Thin wrapper over the shared
* InWorkspaceAppViewer, which renders the app sandboxed (opaque iframe / scoped
* token) through the same machinery as the public viewer. Raw apps use the
* sibling /apps_raw/get route, which wraps the same component.
*/
import { base } from '$lib/base'
import AppPreview from '$lib/components/apps/editor/AppPreview.svelte'
import type { EditorBreakpoint } from '$lib/components/apps/types'
import { Button, Skeleton } from '$lib/components/common'
import { AppService, type AppWithLastVersion } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { canWrite, urlParamsToObject } from '$lib/utils'
import { Pen } from 'lucide-svelte'
import { writable } from 'svelte/store'
import { twMerge } from 'tailwind-merge'
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
import { Skeleton } from '$lib/components/common'
import { workspaceStore } from '$lib/stores'
import { page } from '$app/state'
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
let can_write = $state(false)
async function loadApp() {
app = await AppService.getAppLiteByPath({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
can_write = canWrite(app?.path, app?.extra_perms!, $userStore)
}
$effect(() => {
if ($workspaceStore && page.params.path) {
if (app && page.params.path === app.path) {
console.log('App already loaded')
} else {
loadApp()
}
}
})
const breakpoint = writable<EditorBreakpoint>('lg')
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
let workspace = $derived($workspaceStore ?? '')
let path = $derived(page.params.path ?? '')
</script>
{#if app}
{#key app}
<div
class={twMerge(
'min-h-screen h-full w-full flex flex-col',
app?.value.css?.['app']?.['viewer']?.class,
'wm-app-viewer'
)}
style={app?.value.css?.['app']?.['viewer']?.style}
>
<AppPreview
context={{
email: $userStore?.email,
name: $userStore?.name,
username: $userStore?.username,
groups: $userStore?.groups,
query: urlParamsToObject(page.url.searchParams, { stripReserved: true }),
hash: page.url.hash.substring(1)
}}
workspace={$workspaceStore ?? ''}
summary={app.summary}
app={app.value}
appPath={page.params.path}
{breakpoint}
policy={app.policy}
isEditor={false}
noBackend={false}
{hideRefreshBar}
replaceStateFn={(path) => {
goto(path)
}}
gotoFn={(path, opt) => {
goto(path, opt)
}}
/>
{#if can_write && !hideEditBtn}
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
<Button
size="sm"
startIcon={{ icon: Pen }}
variant="subtle"
href="{base}/apps/edit/{app.path}">Edit</Button
>
</div>
{/if}
</div>
<!-- Wait for the active workspace before mounting the embedder: it's needed to
mint the token and to build the viewer iframe URL, and the store is set
asynchronously by the (logged) layout. -->
{#if workspace && path}
<!-- Key by target: SvelteKit reuses this page component on in-route
navigation (e.g. a navbar item linking to another app), so the viewer
must fully remount — otherwise the previous app (and in sandbox mode its
path-scoped token) sticks around. -->
{#key `${workspace}/${path}`}
<InWorkspaceAppViewer {workspace} {path} editHref="{base}/apps/edit/{path}?nodraft=true" />
{/key}
{:else}
<Skeleton layout={[10]} />
@@ -1,5 +0,0 @@
export function load({ params }) {
return {
stuff: { title: `App ${params.path}` }
}
}
@@ -1,41 +1,17 @@
<script lang="ts">
import { Skeleton } from '$lib/components/common'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { onDestroy, onMount } from 'svelte'
import { goto } from '$app/navigation'
import { base } from '$app/paths'
import { page } from '$app/state'
let loaded = $state(false)
import { onMount } from 'svelte'
onMount(async () => {
globalThis.windmill = {
username: $userStore?.username,
email: $userStore?.email,
workspace: $workspaceStore
}
// //@ts-ignore
// await import('http://localhost:3000/app.iife.js')
/* @vite-ignore */
await import(
/* webpackIgnore: true */
`/api/w/${$workspaceStore}/raw_apps/get_data/${page.params.version}/${page.params.path}`
)
try {
globalThis.render()
} catch (e) {
sendUserToast('App seem to be ill-defined', true)
console.error(e)
}
loaded = true
})
onDestroy(() => {
globalThis.windmill = undefined
// WIN-2006: the old same-origin raw-app viewer (`/apps/get_raw/{version}/{path}`,
// which imported the bundle into the page with no isolation) was removed in favor
// of the sandboxed unified viewer. Redirect stale bookmarks to the new route,
// preserving query + hash. The pinned `version` is dropped — the unified viewer
// always shows the latest, like every other in-workspace app link. Done in the
// component (not a load redirect) because `url.hash` is unavailable in `load`.
onMount(() => {
const path = page.params.path ?? ''
goto(`${base}/apps_raw/get/${path}${page.url.search}${page.url.hash}`, { replaceState: true })
})
</script>
<div id="root"></div>
{#if !loaded}
<Skeleton layout={[10]} />
{/if}
@@ -1,65 +1,26 @@
<script lang="ts">
import { base } from '$app/paths'
import { Button, Skeleton } from '$lib/components/common'
import { AppService, type AppWithLastVersion } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { Pen } from 'lucide-svelte'
import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
/*
* WIN-2006: in-workspace raw app viewer. Thin wrapper over the shared
* InWorkspaceAppViewer (same component as the low-code /apps/get route) so raw
* apps get the identical sandbox behavior. PublicAppFrame renders raw apps
* inline with the bundle isolated in RawAppPreview's own opaque iframe.
*/
import { base } from '$lib/base'
import InWorkspaceAppViewer from '$lib/components/apps/editor/InWorkspaceAppViewer.svelte'
import { Skeleton } from '$lib/components/common'
import { workspaceStore } from '$lib/stores'
import { page } from '$app/state'
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
let app = $state(undefined) as AppWithLastVersion | undefined
let secret = $state(undefined) as string | undefined
async function loadApp() {
console.log('Loading app')
app = await AppService.getAppLiteByPath({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
}
async function loadSecret() {
secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
}
$effect(() => {
$workspaceStore && loadApp()
$workspaceStore && loadSecret()
})
let can_write = $derived(canWrite(page.params.path ?? '', app?.extra_perms ?? {}, $userStore))
function getRunnables(app: AppWithLastVersion) {
return ((app?.value as any)?.runnables ?? {}) as Record<string, Runnable>
}
let workspace = $derived($workspaceStore ?? '')
let path = $derived(page.params.path ?? '')
</script>
<div class="h-full min-h-[600px] w-full relative p-2bg-white">
{#if !$workspaceStore || !$userStore || !app}
<Skeleton layout={[10]} />
{:else}
<RawAppPreview
path={page.params.path ?? ''}
workspace={$workspaceStore}
user={$userStore}
runnables={getRunnables(app)}
{secret}
/>
{/if}
{#if can_write && !hideEditBtn}
<div id="app-edit-btn" class="absolute bottom-4 z-50 right-4">
<Button
size="sm"
startIcon={{ icon: Pen }}
variant="subtle"
href="{base}/apps_raw/edit/{page.params.path}">Edit</Button
>
</div>
{/if}
</div>
{#if workspace && path}
<!-- Key by target: SvelteKit reuses this page component on in-route
navigation, so the viewer must fully remount (see /apps/get). -->
{#key `${workspace}/${path}`}
<InWorkspaceAppViewer {workspace} {path} editHref="{base}/apps_raw/edit/{path}?nodraft=true" />
{/key}
{:else}
<Skeleton layout={[10]} />
{/if}
+61 -19
View File
@@ -3,14 +3,15 @@
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { setContext } from 'svelte'
import { setLicense } from '$lib/enterpriseUtils'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
import { page } from '$app/state'
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
let notExists = $state(false)
@@ -44,16 +45,44 @@
}
}
let workspace: string | undefined = $state(undefined)
async function loadApp() {
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
const parsedCustomPath = parseCustomPath(page.params.path ?? '')
// URL for the opaque viewer iframe: the custom-path URL WITHOUT the trailing
// JWT segment. The JWT is a viewer credential (broader and longer-lived than
// the scoped embed token) consumed here on the embedder side only — it must
// never appear in the iframe's own location, where app-authored code could
// read it. Captured once (not reactively): the embedder mirrors the app's
// hash/query back onto this page's URL, and re-deriving the src from it would
// reload the app on its every navigation.
const viewerUrl = `${base}/a/${parsedCustomPath.path}${page.url.search}${page.url.hash}`
let workspace: string | undefined = $state(undefined)
let refresh: (() => void) | undefined
// Embedder side: validate access (main session cookie or shared JWT) and mint
// a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(): Promise<{ token?: string }> {
if (parsedCustomPath.jwt) {
const token = 'jwt_ext_' + parsedCustomPath.jwt
OpenAPI.TOKEN = token
setContext<{ token?: string }>('AuthToken', { token })
jwtError = false
OpenAPI.TOKEN = 'jwt_ext_' + parsedCustomPath.jwt
}
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const res = await fetch(
`${OpenAPI.BASE}/apps_u/embed_token_by_custom_path/${parsedCustomPath.path}`,
{ headers }
)
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
}
// Viewer side: load the app + user using the embed token handed to the iframe.
async function loadApp() {
try {
app = await AppService.getPublicAppByCustomPath({
customPath: parsedCustomPath.path
@@ -62,9 +91,13 @@
workspaceStore.set(app.workspace_id)
noPermission = false
notExists = false
jwtError = false
try {
userStore.set(await getUserExt(app.workspace_id))
// A JWT in the custom path that fails to resolve a user is surfaced as a
// toast (matches the pre-sandbox custom-path viewer) rather than silently
// falling through to anonymous.
if (!$userStore && parsedCustomPath.jwt) {
jwtError = true
sendUserToast('Could not authentify user with jwt token', true)
@@ -74,7 +107,8 @@
}
} catch (e) {
if (e.status == 401) {
noPermission = true
// Embed token missing/expired — ask the embedder for a fresh one.
refresh?.()
} else {
notExists = true
}
@@ -83,17 +117,25 @@
if (BROWSER) {
setLicense()
loadApp()
}
</script>
<PublicApp
{workspace}
{notExists}
{noPermission}
{jwtError}
{app}
onLoginSuccess={() => {
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
></PublicApp>
>
{#snippet viewer()}
<PublicApp
{workspace}
{notExists}
{noPermission}
{jwtError}
{app}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
@@ -0,0 +1,99 @@
<script lang="ts">
/*
* WIN-2006: in-workspace app viewer, sandboxed. This is the private analog of
* the public `/public/[workspace]/[...secret]` route — same `PublicAppFrame`
* embedder/viewer + scoped-token machinery, but the app is addressed by path and
* the token is minted from the logged-in member's session (cookie) via the
* authenticated `apps/embed_token/p/{path}` endpoint. It lives outside `(logged)`
* so the opaque (cookieless) viewer iframe can load it without the auth redirect
* or the workspace chrome. `/apps/get` embeds this route in an opaque iframe.
*/
import { BROWSER } from 'esm-env'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { setLicense } from '$lib/enterpriseUtils'
import { getUserExt } from '$lib/user'
import { page } from '$app/state'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
let app: any = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
const workspace = page.params.workspace ?? ''
const path = page.params.path ?? ''
// Forwarded by InWorkspaceAppViewer from the member-facing page's query.
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
let refresh: (() => void) | undefined
// Embedder side: the logged-in member's session mints a scoped embed token for
// the opaque iframe, isolating the in-workspace app from their full session.
async function fetchEmbedToken(): Promise<{ token?: string }> {
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}`, {
headers
})
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
}
// Viewer side: load the app + user using the embed token handed to the iframe.
async function loadApp() {
// Set the workspace store so app job operations (notably JobLoader.cancelJob,
// which reads $workspaceStore) target this workspace instead of an empty/stale
// one in the cookieless iframe.
workspaceStore.set(workspace)
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
app = await AppService.getAppByPath({ workspace, path })
noPermission = false
notExists = false
} catch (e: any) {
if (e.status == 401) {
// Embed token missing/expired — ask the embedder for a fresh one.
refresh?.()
} else if (e.status == 403) {
noPermission = true
} else {
notExists = true
}
}
}
if (BROWSER) {
setLicense()
}
</script>
<PublicAppFrame
{fetchEmbedToken}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
@@ -1,5 +0,0 @@
export function load({ params }) {
return {
stuff: { title: `Public App` }
}
}
@@ -1,11 +0,0 @@
<script lang="ts">
// import { page } from '$app/state'
// import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
// import { userStore } from '$lib/stores'
</script>
<!-- <RawAppPreview
workspace={page.params.workspace}
user={$userStore}
version={Number(page.params.version)}
/> -->
@@ -4,21 +4,20 @@
import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen'
import { userStore } from '$lib/stores'
import { setContext } from 'svelte'
import { setLicense } from '$lib/enterpriseUtils'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
import { page } from '$app/state'
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let jwtError = $state(false)
function parseSecret(secret: string): { secret: string; jwt: string } {
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
const parts = secret.split('/')
return {
secret: parts[0],
@@ -27,18 +26,59 @@
}
const parsedSecret = parseSecret(page.params.secret ?? '')
const workspace = page.params.workspace ?? ''
// URL for the opaque viewer iframe: the share URL WITHOUT the trailing JWT
// segment. The JWT is a viewer credential (broader and longer-lived than the
// scoped embed token) consumed here on the embedder side only — it must never
// appear in the iframe's own location, where app-authored code could read it.
// Captured once (not reactively): the embedder mirrors the app's hash/query
// back onto this page's URL, and re-deriving the src from it would reload the
// app on its every navigation.
const viewerUrl = `${base}/public/${workspace}/${parsedSecret.secret}${page.url.search}${page.url.hash}`
let refresh: (() => void) | undefined
// Embedder side: validate access (using the main session cookie or the shared
// JWT) and mint a scoped embed token for the opaque iframe (WIN-2006).
async function fetchEmbedToken(): Promise<{ token?: string }> {
if (parsedSecret.jwt) {
OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt
}
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const res = await fetch(
`${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}`,
{ headers }
)
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
}
// Viewer side: load the app + user using the embed token handed to the iframe.
async function loadApp() {
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
app = await AppService.getPublicAppBySecret({
workspace: page.params.workspace ?? '',
workspace,
path: parsedSecret.secret
})
noPermission = false
notExists = false
} catch (e) {
if (e.status == 401) {
noPermission = true
// Embed token missing/expired — ask the embedder for a fresh one.
refresh?.()
} else {
notExists = true
}
@@ -47,42 +87,25 @@
if (BROWSER) {
setLicense()
loadAll()
}
function loadAll() {
console.log('loadAll')
loadUser().then(() => {
loadApp()
})
}
async function loadUser() {
if (parsedSecret.jwt) {
const token = 'jwt_ext_' + parsedSecret.jwt
OpenAPI.TOKEN = token
setContext<{ token?: string }>('AuthToken', { token })
jwtError = false
}
try {
userStore.set(await getUserExt(page.params.workspace ?? ''))
if (!$userStore && parsedSecret.jwt) {
jwtError = true
sendUserToast('Could not authentify user with jwt token', true)
}
} catch (e) {
console.warn('Anonymous user')
}
}
</script>
<PublicApp
{app}
workspace={page.params.workspace}
{notExists}
{noPermission}
{jwtError}
onLoginSuccess={() => {
loadAll()
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
></PublicApp>
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
{jwtError}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.736.0"
wmill = ">=1.737.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.736.0
version: 1.737.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.736.0'
ModuleVersion = '1.737.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.736.0"
version = "1.737.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.736.0",
"version": "1.737.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.736.0",
"version": "1.737.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.736.0
1.737.0