diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index c0b2b581cc..6759d9107e 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -489,8 +489,12 @@ pub fn check_route_access( } } - // Raw-app SDK tokens (sentinel) hold `jobs:run` only to run the deployed - // runnables the viewer already can — never request-supplied code. + // Raw-app SDK tokens (sentinel). The viewer approved a named list of + // permissions, so each must grant what its prompt says and no more: + // `jobs:run` runs the deployed runnables the viewer already can — never + // request-supplied code — and `users:read` reads their own identity, not the + // workspace member directory (`users/list`, `users/list_usage`) the domain + // scope would otherwise reach. if has_raw_app_sdk_sentinel(Some(token_scopes)) { if let Some(suffix) = route_suffix.as_deref() { if is_request_supplied_code_route(suffix) { @@ -499,6 +503,12 @@ pub fn check_route_access( .to_string(), )); } + if required_domain == ScopeDomain::Users && suffix != "users/whoami" { + return Err(Error::PermissionDenied( + "Access denied. A raw app frontend SDK token can only read the viewer's own identity." + .to_string(), + )); + } } } diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 6930e819eb..e37bf32585 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4974,6 +4974,11 @@ mod embed_token_tests { ("/api/w/test/jobs/run/dependencies_async", "POST"), ("/api/w/test/jobs/run/flow_dependencies", "POST"), ("/api/w/test/jobs/run/flow_dependencies_async", "POST"), + // `users:read` is presented to the viewer as "read your identity", so + // the workspace member directory must stay out of reach. + ("/api/w/test/users/list", "GET"), + ("/api/w/test/users/list_usage", "GET"), + ("/api/w/test/users/username_to_email/admin", "GET"), ]; for (path, method) in denied { assert!( diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.test.ts b/frontend/src/lib/components/raw_apps/sdkScopes.test.ts new file mode 100644 index 0000000000..68aba9c545 --- /dev/null +++ b/frontend/src/lib/components/raw_apps/sdkScopes.test.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { hasStoredSdkConsent, storeSdkConsent } from './sdkScopes' + +describe('stored frontend SDK consent', () => { + beforeEach(() => localStorage.clear()) + + it('only covers scopes the viewer actually approved', () => { + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) + // The app added a scope after the viewer consented: it must ask again + // rather than silently minting a broader token. + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read', 'jobs:run'])).toBe(false) + }) + + it('does not leak one viewer or app to another', () => { + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) + expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['users:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['users:read'])).toBe(false) + }) + + it('treats unreadable storage as no consent', () => { + localStorage.setItem('wm_sdk_consent:a@w.dev:ws:u/a/app', 'not json') + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.ts b/frontend/src/lib/components/raw_apps/sdkScopes.ts index 983de32fe2..9a825fc02b 100644 --- a/frontend/src/lib/components/raw_apps/sdkScopes.ts +++ b/frontend/src/lib/components/raw_apps/sdkScopes.ts @@ -12,12 +12,12 @@ export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: s { value: 'jobs:read', label: 'Read jobs and results', - description: 'Poll jobs by id and read their results' + description: 'Read jobs and their results, and list the runs the viewer can see' }, { value: 'users:read', label: 'Read your identity', - description: 'Read the viewer username and email (whoami)' + description: 'Read the viewer username and email — not the workspace member list' }, { value: 'resources:read',