fix(web): tus uploads use per-origin token (federation auth)
This commit is contained in:
@@ -79,6 +79,7 @@ Stats: `getStorageStats()` exposes `staleTusSessions` + `staleTusSize` for the a
|
|||||||
### Security
|
### Security
|
||||||
|
|
||||||
- JWT verified on every tus request (PRE_CREATE, PRE_PATCH, finalize, HEAD, DELETE).
|
- JWT verified on every tus request (PRE_CREATE, PRE_PATCH, finalize, HEAD, DELETE).
|
||||||
|
- **Federated uploads use a per-origin JWT.** When the target space is hosted on a remote instance, the client must send that instance's scoped token (resolved via `getTokenForOrigin(origin)` in `crossStoreResolvers.ts`), not the home-instance token — otherwise the remote rejects the request as it can't verify the home signature or resolve the userId.
|
||||||
- PRE_PATCH ownership check: `metadata.userId === req.user.id`. Required to prevent in-flight upload hijack between session creation and finalize.
|
- PRE_PATCH ownership check: `metadata.userId === req.user.id`. Required to prevent in-flight upload hijack between session creation and finalize.
|
||||||
- Size validated against `instance_settings.maxUploadSizeBytes` at PRE_CREATE; tus's own `maxSize` is set as defense-in-depth.
|
- Size validated against `instance_settings.maxUploadSizeBytes` at PRE_CREATE; tus's own `maxSize` is set as defense-in-depth.
|
||||||
- Original filename round-trips through tus metadata (base64-encoded per spec); the on-disk filename uses snowflake + sanitized extension only.
|
- Original filename round-trips through tus metadata (base64-encoded per spec); the on-disk filename uses snowflake + sanitized extension only.
|
||||||
@@ -466,7 +467,7 @@ Three stores with strict separation of concerns:
|
|||||||
|
|
||||||
| Store | Source file | Ownership |
|
| Store | Source file | Ownership |
|
||||||
|-------|-------------|-----------|
|
|-------|-------------|-----------|
|
||||||
| `transferStore` | `packages/web/src/stores/transferStore.ts` | Every byte transfer (uploads + downloads). Source of truth for the global tray. Persists transfer metadata via `transferStore@v1`. |
|
| `transferStore` | `packages/web/src/stores/transferStore.ts` | Every byte transfer (uploads + downloads). Source of truth for the global tray. Persists transfer metadata via `transferStore@v1`. Tus uploads route the bearer token through `getTokenForOrigin(origin)` so federated uploads use the per-instance JWT, not the home-instance one. |
|
||||||
| `composerStore` | `packages/web/src/stores/composerStore.ts` | Per-channel staged transfer IDs + draft text + replyTo. Replaces `MessageInput` component-local state. Persists via `composerStore@v1`. |
|
| `composerStore` | `packages/web/src/stores/composerStore.ts` | Per-channel staged transfer IDs + draft text + replyTo. Replaces `MessageInput` component-local state. Persists via `composerStore@v1`. |
|
||||||
| `pendingMessageStore` | `packages/web/src/stores/pendingMessageStore.ts` | Composed-but-not-yet-sent attachment-bearing bubbles, keyed by `clientId`. Persists via `pendingMessageStore@v1`. Text-only messages are out of scope -- they keep the existing `chatStore.sendMessage` `temp_*` optimistic path. |
|
| `pendingMessageStore` | `packages/web/src/stores/pendingMessageStore.ts` | Composed-but-not-yet-sent attachment-bearing bubbles, keyed by `clientId`. Persists via `pendingMessageStore@v1`. Text-only messages are out of scope -- they keep the existing `chatStore.sendMessage` `temp_*` optimistic path. |
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
setApiForOriginResolver,
|
setApiForOriginResolver,
|
||||||
setUserIdForOriginResolver,
|
setUserIdForOriginResolver,
|
||||||
setOriginFromHostnameResolver,
|
setOriginFromHostnameResolver,
|
||||||
|
setTokenForOriginResolver,
|
||||||
} from '../utils/crossStoreResolvers';
|
} from '../utils/crossStoreResolvers';
|
||||||
import { useSpaceStore } from './spaceStore';
|
import { useSpaceStore } from './spaceStore';
|
||||||
import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemote } from '../hooks/useWebSocket';
|
import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemote } from '../hooks/useWebSocket';
|
||||||
@@ -1112,6 +1113,18 @@ setUserIdForOriginResolver((origin: string): string | undefined => {
|
|||||||
return instance?.user.id;
|
return instance?.user.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Token resolution (federation) ────────────────────────────────────────────
|
||||||
|
// Maps an origin to the JWT for that instance. Used by transferStore for tus
|
||||||
|
// uploads and any other path that constructs raw HTTP requests to a federated
|
||||||
|
// instance and needs to pass an Authorization header. Empty origin falls back
|
||||||
|
// to the home-instance token from authStore.
|
||||||
|
|
||||||
|
setTokenForOriginResolver((origin: string): string | null => {
|
||||||
|
if (!origin) return useAuthStore.getState().token;
|
||||||
|
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
|
||||||
|
return instance?.token ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Electron: push connected-instance origins to main process ───────────────
|
// ─── Electron: push connected-instance origins to main process ───────────────
|
||||||
// Enables the main process to intercept invite URLs that point to instances
|
// Enables the main process to intercept invite URLs that point to instances
|
||||||
// we're already signed into. Uses the basic subscribe(listener) form (no
|
// we're already signed into. Uses the basic subscribe(listener) form (no
|
||||||
|
|||||||
@@ -976,6 +976,8 @@ export {
|
|||||||
setOriginFromHostnameResolver,
|
setOriginFromHostnameResolver,
|
||||||
setUserIdForOriginResolver,
|
setUserIdForOriginResolver,
|
||||||
setMyUserIdForOrigin,
|
setMyUserIdForOrigin,
|
||||||
|
setTokenForOriginResolver,
|
||||||
|
getTokenForOrigin,
|
||||||
} from '../utils/crossStoreResolvers';
|
} from '../utils/crossStoreResolvers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist, type PersistStorage, type StorageValue } from 'zustand/middlew
|
|||||||
import { Upload, type UploadOptions } from 'tus-js-client';
|
import { Upload, type UploadOptions } from 'tus-js-client';
|
||||||
import type { Attachment } from '@backspace/shared';
|
import type { Attachment } from '@backspace/shared';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
import { getTokenForOrigin } from '../utils/crossStoreResolvers';
|
||||||
|
|
||||||
export type TransferType = 'upload' | 'download';
|
export type TransferType = 'upload' | 'download';
|
||||||
export type TransferState =
|
export type TransferState =
|
||||||
@@ -229,7 +230,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
startUpload: async (file, opts) => {
|
startUpload: async (file, opts) => {
|
||||||
const token = useAuthStore.getState().token;
|
const token = getTokenForOrigin(opts.origin ?? '');
|
||||||
const user = useAuthStore.getState().user;
|
const user = useAuthStore.getState().user;
|
||||||
if (!token) throw new Error('Cannot start upload — not authenticated');
|
if (!token) throw new Error('Cannot start upload — not authenticated');
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
// No live instance, but server-side .tus state still exists (e.g., this
|
// No live instance, but server-side .tus state still exists (e.g., this
|
||||||
// transfer failed mid-flight or was paused with a stored URL). Send DELETE
|
// transfer failed mid-flight or was paused with a stored URL). Send DELETE
|
||||||
// directly so the partial bytes don't sit on disk until the janitor sweeps.
|
// directly so the partial bytes don't sit on disk until the janitor sweeps.
|
||||||
const token = useAuthStore.getState().token;
|
const token = getTokenForOrigin(t.origin ?? '');
|
||||||
if (token) {
|
if (token) {
|
||||||
const fullUrl = t.tusUploadUrl.startsWith('http')
|
const fullUrl = t.tusUploadUrl.startsWith('http')
|
||||||
? t.tusUploadUrl
|
? t.tusUploadUrl
|
||||||
@@ -349,9 +350,9 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
const t = get().get(id);
|
const t = get().get(id);
|
||||||
if (!t || t.type !== 'upload') return;
|
if (!t || t.type !== 'upload') return;
|
||||||
|
|
||||||
const token = useAuthStore.getState().token;
|
const token = getTokenForOrigin(t.origin ?? '');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
get().setError(id, { message: 'Not authenticated', permanent: true });
|
get().setError(id, { message: 'Not authenticated for this instance', permanent: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import 'fake-indexeddb/auto';
|
|||||||
// We mock tus-js-client to drive lifecycle synchronously without real network.
|
// We mock tus-js-client to drive lifecycle synchronously without real network.
|
||||||
const startMock = vi.fn();
|
const startMock = vi.fn();
|
||||||
const abortMock = vi.fn().mockResolvedValue(undefined);
|
const abortMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
// Captures every set of UploadOptions handed to `new Upload(...)`. Tests can
|
||||||
|
// inspect `lastUploadOpts()` to assert on endpoint + Authorization header,
|
||||||
|
// which is the only way we verify per-origin token routing without hitting
|
||||||
|
// the network.
|
||||||
|
const constructedOpts: any[] = [];
|
||||||
|
function lastUploadOpts(): any {
|
||||||
|
return constructedOpts[constructedOpts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('tus-js-client', () => {
|
vi.mock('tus-js-client', () => {
|
||||||
class MockUpload {
|
class MockUpload {
|
||||||
@@ -11,6 +19,7 @@ vi.mock('tus-js-client', () => {
|
|||||||
public url: string | null = null;
|
public url: string | null = null;
|
||||||
constructor(_file: File, opts: any) {
|
constructor(_file: File, opts: any) {
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
|
constructedOpts.push(opts);
|
||||||
}
|
}
|
||||||
start() {
|
start() {
|
||||||
startMock();
|
startMock();
|
||||||
@@ -59,13 +68,27 @@ vi.mock('./authStore', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { useTransferStore } from './transferStore';
|
import { useTransferStore } from './transferStore';
|
||||||
|
import { setTokenForOriginResolver } from '../utils/crossStoreResolvers';
|
||||||
|
import { useAuthStore as authStoreMod } from './authStore';
|
||||||
|
|
||||||
|
// Default resolver mirrors prod wiring: empty origin → home authStore token,
|
||||||
|
// any other origin → null (no federated instance is wired up in tests unless
|
||||||
|
// the test explicitly registers a different resolver).
|
||||||
|
function installDefaultTokenResolver(): void {
|
||||||
|
setTokenForOriginResolver((origin: string): string | null => {
|
||||||
|
if (origin) return null;
|
||||||
|
return authStoreMod.getState().token ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('transferStore.startUpload', () => {
|
describe('transferStore.startUpload', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
|
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
|
||||||
startMock.mockClear();
|
startMock.mockClear();
|
||||||
abortMock.mockClear();
|
abortMock.mockClear();
|
||||||
|
constructedOpts.length = 0;
|
||||||
if (typeof localStorage !== 'undefined') localStorage.clear();
|
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||||
|
installDefaultTokenResolver();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drives a transfer through tus → completed with attachmentId + attachmentFilename', async () => {
|
it('drives a transfer through tus → completed with attachmentId + attachmentFilename', async () => {
|
||||||
@@ -114,6 +137,47 @@ describe('transferStore.startUpload', () => {
|
|||||||
useTransferStore.getState().pauseUpload(id);
|
useTransferStore.getState().pauseUpload(id);
|
||||||
expect(useTransferStore.getState().get(id)!.state).toBe('paused');
|
expect(useTransferStore.getState().get(id)!.state).toBe('paused');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('startUpload uses the per-origin token + endpoint for federated uploads', async () => {
|
||||||
|
// Register a resolver that returns a federated token for a specific origin
|
||||||
|
// and the home token (from the mocked authStore) for the empty origin.
|
||||||
|
setTokenForOriginResolver((origin: string): string | null => {
|
||||||
|
if (origin === 'https://remote.example.com') return 'federated-token-XYZ';
|
||||||
|
if (!origin) return authStoreMod.getState().token ?? null;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' });
|
||||||
|
const id = await useTransferStore.getState().startUpload(file, {
|
||||||
|
tray: true,
|
||||||
|
origin: 'https://remote.example.com',
|
||||||
|
});
|
||||||
|
const t = useTransferStore.getState().get(id);
|
||||||
|
expect(t).toBeDefined();
|
||||||
|
// The synchronous mock drives the full lifecycle, so we end completed.
|
||||||
|
expect(t!.state).toBe('completed');
|
||||||
|
// The transfer carries the remote origin so resume/abort route correctly.
|
||||||
|
expect(t!.origin).toBe('https://remote.example.com');
|
||||||
|
// The tus client was constructed with the federated bearer + remote endpoint —
|
||||||
|
// proves we did NOT fall back to the home authStore token.
|
||||||
|
const opts = lastUploadOpts();
|
||||||
|
expect(opts.endpoint).toBe('https://remote.example.com/api/files/');
|
||||||
|
expect(opts.headers.Authorization).toBe('Bearer federated-token-XYZ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startUpload throws when no resolver is registered for the federated origin', async () => {
|
||||||
|
// Resolver returns null for unknown origin → no fallback to home token.
|
||||||
|
setTokenForOriginResolver((origin: string): string | null => {
|
||||||
|
if (!origin) return authStoreMod.getState().token ?? null;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const file = new File([new Uint8Array(10)], 'a.png', { type: 'image/png' });
|
||||||
|
await expect(
|
||||||
|
useTransferStore.getState().startUpload(file, {
|
||||||
|
tray: true,
|
||||||
|
origin: 'https://unknown.example.com',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/not authenticated/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('transferStore.resumeUpload', () => {
|
describe('transferStore.resumeUpload', () => {
|
||||||
@@ -121,6 +185,7 @@ describe('transferStore.resumeUpload', () => {
|
|||||||
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
|
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
|
||||||
startMock.mockClear();
|
startMock.mockClear();
|
||||||
abortMock.mockClear();
|
abortMock.mockClear();
|
||||||
|
installDefaultTokenResolver();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks failed when transfer has no available blob to resume from', async () => {
|
it('marks failed when transfer has no available blob to resume from', async () => {
|
||||||
@@ -177,6 +242,7 @@ describe('transferStore.hasInMemoryFile', () => {
|
|||||||
startMock.mockClear();
|
startMock.mockClear();
|
||||||
abortMock.mockClear();
|
abortMock.mockClear();
|
||||||
if (typeof localStorage !== 'undefined') localStorage.clear();
|
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||||
|
installDefaultTokenResolver();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hasInMemoryFile tracks startUpload + remove lifecycle', async () => {
|
it('hasInMemoryFile tracks startUpload + remove lifecycle', async () => {
|
||||||
|
|||||||
@@ -97,3 +97,32 @@ export function getCachedUserIdForOrigin(origin: string): string | undefined {
|
|||||||
export function clearMyUserIdCache(): void {
|
export function clearMyUserIdCache(): void {
|
||||||
_myUserIdByOrigin.clear();
|
_myUserIdByOrigin.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Token resolution (federation) ────────────────────────────────────────────
|
||||||
|
// Registered by instanceStore on import; maps an origin to the local user's
|
||||||
|
// JWT for that instance. Used by transferStore (tus uploads) and any other
|
||||||
|
// path that constructs raw HTTP requests to a federated instance and needs to
|
||||||
|
// pass an Authorization header.
|
||||||
|
|
||||||
|
let _getTokenForOrigin: ((origin: string) => string | null) | null = null;
|
||||||
|
|
||||||
|
export function setTokenForOriginResolver(
|
||||||
|
resolver: (origin: string) => string | null,
|
||||||
|
): void {
|
||||||
|
_getTokenForOrigin = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JWT to use when calling APIs on the given origin.
|
||||||
|
* - Empty origin → home-instance token from authStore.
|
||||||
|
* - Connected remote → that instance's scoped token.
|
||||||
|
* - Unknown / not-yet-connected → null.
|
||||||
|
*
|
||||||
|
* Note: this module imports nothing from `./stores/*` to avoid TDZ cycles.
|
||||||
|
* The home-instance fallback is supplied by the caller via the resolver itself
|
||||||
|
* (instanceStore registers a resolver that knows how to read authStore for `''`).
|
||||||
|
*/
|
||||||
|
export function getTokenForOrigin(origin: string): string | null {
|
||||||
|
if (!_getTokenForOrigin) return null;
|
||||||
|
return _getTokenForOrigin(origin);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user