Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/action-runtime-same-origin-only-5702.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<typeof import('@object-ui/auth')>();
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<typeof import('@object-ui/i18n')>();
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();
});
});
19 changes: 15 additions & 4 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 });
Expand All@@ -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) {
Expand Down
Loading