diff --git a/.changeset/action-runtime-same-origin-only-5702.md b/.changeset/action-runtime-same-origin-only-5702.md new file mode 100644 index 0000000000..1340b71436 --- /dev/null +++ b/.changeset/action-runtime-same-origin-only-5702.md @@ -0,0 +1,25 @@ +--- +'@object-ui/app-shell': patch +--- + +**Behaviour change:** the console action runtime (`useConsoleActionRuntime`) now +builds its authenticated fetch with `sameOriginOnly: true`, matching the +`provider: 'api'` data-source lane (`ConsoleShell`). A metadata `type: 'api'` +action whose resolved target is a different origin than the page is fetched +through the bare global fetch: the platform Bearer token, `X-Tenant-ID`, and +`Accept-Language` are no longer attached (objectui#5702, maintainer ruling +2026-08-22). Previously the Bearer rode to any off-origin target whose URL +contained `/api/`, and `X-Tenant-ID` rode to every off-origin target +unconditionally. + +Same-origin actions — including every relative target in a same-origin +deployment — are unchanged, and off-origin requests still execute (pass-through, +not a refusal). An off-origin integration that legitimately needs the platform +bearer declares itself explicitly or proxies same-origin. + +Note for split-host setups: `apiHandler` prefixes relative action targets with +`VITE_SERVER_URL`. When that is set to an origin different from the page's, +those action requests are off-origin and no longer carry credentials. The +committed dev and starter configurations are unaffected: since objectui#5745 +they ship `VITE_SERVER_URL` empty and reach a split-host backend through the +Vite `/api` dev proxy, so their action requests are same-origin. diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.sameOriginOnly-5702.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.sameOriginOnly-5702.test.tsx new file mode 100644 index 0000000000..82d146b56f --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.sameOriginOnly-5702.test.tsx @@ -0,0 +1,168 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * #5702 regression pin — the console action runtime builds its authenticated + * fetch with `sameOriginOnly: true` (the #2725 mitigation, ruled onto this + * lane: a metadata `type: 'api'` action's `target` is author-supplied and may + * name an off-origin host that must never see the platform bearer or the + * tenant header). + * + * The pin is deliberately a PAIR, exercised through `apiHandler` with the + * REAL `createAuthenticatedFetch` (not the whole-module auth mock the sibling + * test file uses — that mock never sees the option, so it cannot pin this): + * + * 1. a same-origin action target still carries Authorization AND + * X-Tenant-ID — the ruled change must leave same-origin actions + * untouched; + * 2. an absolute off-origin target carries NEITHER — and the request still + * goes out (pass-through to the bare fetch, not a refusal). + * + * Either half alone proves nothing: the off-origin half is green on a wrapper + * that attaches no headers at all, and the same-origin half is green on the + * bare wrapper this call site built before #5702. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); + +// PARTIAL auth mock: `useAuth` is stubbed (renderHook mounts no AuthProvider), +// but `createAuthenticatedFetch`, `TokenStorage` and `ActiveOrganizationStorage` +// stay real — the point of this file is to observe which headers the wrapper +// the hook builds actually attaches. +vi.mock('@object-ui/auth', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + useAuth: () => ({ + user: { id: 'u1', name: 'User', image: null }, + activeOrganization: null, + }), + }; +}); + +// Partial as well — the light-dom import graph reaches other @object-ui/i18n +// exports (e.g. `createSafeTranslation` via @object-ui/components); only the +// two hooks the runtime reads are stubbed. +vi.mock('@object-ui/i18n', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + useObjectLabel: () => ({ + fieldLabel: (_o: any, _n: any, l: any) => l, + fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l, + actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback, + actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, + actionDescription: (_o: any, _a: any, fallback: any) => fallback, + }), + useObjectTranslation: () => ({ + t: (key: string, options?: any) => String(options?.defaultValue ?? key), + }), + }; +}); + +vi.mock('../useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null })); + +vi.mock('sonner', () => { + const fn: any = vi.fn(); + fn.error = vi.fn(); + fn.success = vi.fn(); + return { toast: fn }; +}); + +import { TokenStorage, ActiveOrganizationStorage } from '@object-ui/auth'; +import { useConsoleActionRuntime } from '../useConsoleActionRuntime'; + +/** Stub the global fetch and capture each call's URL + resolved Headers. */ +function stubFetch() { + const calls: Array<{ url: string; headers: Headers }> = []; + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + calls.push({ url, headers: new Headers(init?.headers) }); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + vi.stubGlobal('fetch', mock); + return calls; +} + +describe('useConsoleActionRuntime — sameOriginOnly on the action lane (#5702)', () => { + beforeEach(() => { + ActiveOrganizationStorage.clear(); + ActiveOrganizationStorage.set('org-5702'); + vi.spyOn(TokenStorage, 'get').mockReturnValue('tok-5702'); + }); + + afterEach(() => { + ActiveOrganizationStorage.clear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('same-origin action target still carries Authorization and X-Tenant-ID', async () => { + const calls = stubFetch(); + const { result } = renderHook(() => + useConsoleActionRuntime({ dataSource: {}, objects: [] }), + ); + + let res: any; + await act(async () => { + res = await result.current.apiHandler({ + type: 'api', + name: 'sameOriginAction', + target: '/api/v1/env-5702', + params: { name: 'x' }, + } as any); + }); + + expect(res).toMatchObject({ success: true }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('/api/v1/env-5702'); + expect(calls[0].headers.get('Authorization')).toBe('Bearer tok-5702'); + expect(calls[0].headers.get('X-Tenant-ID')).toBe('org-5702'); + }); + + it('absolute off-origin target carries neither header — and the request still goes out', async () => { + const calls = stubFetch(); + const { result } = renderHook(() => + useConsoleActionRuntime({ dataSource: {}, objects: [] }), + ); + + let res: any; + await act(async () => { + res = await result.current.apiHandler({ + type: 'api', + name: 'offOriginAction', + target: 'https://third-party.example.com/api/hook', + params: { name: 'x' }, + } as any); + }); + + // Pass-through, not a refusal: the request executes against the bare fetch. + expect(res).toMatchObject({ success: true }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('https://third-party.example.com/api/hook'); + expect(calls[0].headers.get('Authorization')).toBeNull(); + expect(calls[0].headers.get('X-Tenant-ID')).toBeNull(); + expect(calls[0].headers.get('Accept-Language')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index e7b19d5e65..007767d5e5 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -305,7 +305,15 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons }, [navigate]); // Authenticated fetch for direct backend calls. Declared before apiHandler. - const authFetch = useMemo(() => createAuthenticatedFetch(), []); + // + // `sameOriginOnly` (#2725 mitigation, applied to this lane per the #5702 + // ruling): an action's `target` comes from author-supplied metadata and may + // name an off-origin host. Cross-origin requests pass through to the bare + // global fetch — no Authorization, X-Tenant-ID, or Accept-Language — matching + // the `provider: 'api'` data-source lane (ConsoleShell). Same-origin actions + // are unaffected. An off-origin integration that legitimately needs the + // platform bearer declares itself explicitly or proxies same-origin. + const authFetch = useMemo(() => createAuthenticatedFetch({ sameOriginOnly: true }), []); const openEntitlementDialog = useCallback((spec: EntitlementDialogSpec) => { setEntitlementDialog({ open: true, spec }); @@ -317,9 +325,12 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const params = action.params || {}; // Absolute HTTP target — bypass dataSource and call the API directly - // through the authenticated fetch wrapper (Bearer + X-Tenant-ID + - // same-origin cookies). The canonical path for schema actions on - // managed-by tables and global page actions. + // through the authenticated fetch wrapper. Same-origin targets get + // Bearer + X-Tenant-ID + same-origin cookies; an off-origin target + // (the metadata may name a third-party host) goes out through the bare + // global fetch with none of them (`sameOriginOnly`, #5702). The + // canonical path for schema actions on managed-by tables and global + // page actions. const targetStr = typeof target === 'string' ? target : ''; const isAbsolute = targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr); if (isAbsolute) {