Skip to content
Open
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
22 changes: 20 additions & 2 deletions src/backend/controllers/oidc/OIDCController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,9 +424,14 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
return String(oidc().verifyState(state!)?.redirect_uri);
};

const returnToFor = (paths: string[], path = '/') => {
const returnToFor = (
paths: string[],
path = '/',
recipientUuid?: string,
) => {
const params = new URLSearchParams();
for (const p of paths) params.append('shared', p);
if (recipientUuid) params.set('user_uuid', recipientUuid);
return `${path}?${params.toString()}`;
};

Expand Down Expand Up @@ -460,6 +465,16 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
]);
});

it('carries a valid recipient account hint with the share', async () => {
const recipientUuid = uuidv4();
const uri = await redirectUriFor(
returnToFor([sharedPath('Report.pdf')], '/', recipientUuid),
);
expect(new URL(uri).searchParams.get('user_uuid')).toBe(
recipientUuid,
);
});

it('carries no more items than a share link may name', async () => {
const paths = Array.from({ length: 25 }, (_, i) =>
sharedPath(`file-${i}.txt`),
Expand All @@ -481,6 +496,8 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
// a parameter that isn't `shared`, alone or alongside one
'/?x=1',
`${returnToFor([sharedPath('a.txt')])}&x=1`,
`${returnToFor([sharedPath('a.txt')])}&user_uuid=not-a-uuid`,
`${returnToFor([sharedPath('a.txt')])}&user_uuid=${uuidv4()}&user_uuid=${uuidv4()}`,
// the root is only a destination when it names something
'/',
// still no origin smuggling, share link or not
Expand Down Expand Up @@ -830,7 +847,7 @@ describe('OIDCController login callback', () => {

const state = oidc().signState({
provider: 'custom',
redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}`,
redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}&user_uuid=${encodeURIComponent(created.user!.uuid)}`,
});
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
Expand All @@ -853,6 +870,7 @@ describe('OIDCController login callback', () => {
expect(url.searchParams.get('auth_error')).toBe('1');
expect(url.searchParams.get('action')).toBe('login');
expect(url.searchParams.getAll('shared')).toEqual([shared]);
expect(url.searchParams.get('user_uuid')).toBe(created.user!.uuid);
});

it('redirects back to an /app/<name> landing after sign-in', async () => {
Expand Down
35 changes: 28 additions & 7 deletions src/backend/controllers/oidc/OIDCController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import crypto from 'node:crypto';
import { validate as validateUuid } from 'uuid';
import type { Request, Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
Expand All @@ -27,6 +28,7 @@ import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
import {
SHARE_DEEP_LINK_ITEMS_LIMIT,
SHARE_DEEP_LINK_PARAM,
SHARE_RECIPIENT_PARAM,
} from '../../services/share/shareDeepLink.js';

const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
Expand Down Expand Up @@ -98,9 +100,18 @@ function isWhitelistedReturnPath(path: string): boolean {
* text, so a hand-edited one is refused rather than reflected back into the
* browser.
*/
function sharedPathsFromReturnQuery(query: string): string[] | null {
function sharedReturnQuery(query: string): {
paths: string[];
recipientUuid?: string;
} | null {
const paths: string[] = [];
let recipientUuid: string | undefined;
for (const [key, value] of new URLSearchParams(query)) {
if (key === SHARE_RECIPIENT_PARAM) {
if (recipientUuid || !validateUuid(value)) return null;
recipientUuid = value;
continue;
}
if (key !== SHARE_DEEP_LINK_PARAM) return null;
const parsed = parseMaskedSharePath(value);
// The segment after the uuid is the shared item itself. A mask without
Expand All @@ -116,7 +127,7 @@ function sharedPathsFromReturnQuery(query: string): string[] | null {
paths.push(value);
}
}
return paths;
return { paths, recipientUuid };
}

/**
Expand All @@ -135,15 +146,19 @@ function sanitizeReturnTo(raw: string): string | null {

const shared =
separator === -1
? []
: sharedPathsFromReturnQuery(raw.slice(separator + 1));
? { paths: [] }
: sharedReturnQuery(raw.slice(separator + 1));
if (shared === null) return null;
// The root is only a destination when it names something: on its own it is
// where the flow already lands.
if (shared.length === 0) return path === '/' ? null : path;
if (shared.paths.length === 0) return path === '/' ? null : path;

const params = new URLSearchParams();
for (const value of shared) params.append(SHARE_DEEP_LINK_PARAM, value);
for (const value of shared.paths)
params.append(SHARE_DEEP_LINK_PARAM, value);
if (shared.recipientUuid) {
params.set(SHARE_RECIPIENT_PARAM, shared.recipientUuid);
}
return `${path}?${params.toString()}`;
}

Expand Down Expand Up @@ -175,12 +190,15 @@ function buildErrorRedirectUrl(
// its success reloads it, so they have to be on it to survive.
let pagePath = '/';
let sharedPaths: string[] = [];
let shareRecipientUuid: string | undefined;
if (typeof stateDecoded?.redirect_uri === 'string') {
try {
const stateUrl = new URL(stateDecoded.redirect_uri);
if (isWhitelistedReturnPath(stateUrl.pathname)) {
pagePath = stateUrl.pathname;
sharedPaths = sharedPathsFromReturnQuery(stateUrl.search) ?? [];
const shared = sharedReturnQuery(stateUrl.search);
sharedPaths = shared?.paths ?? [];
shareRecipientUuid = shared?.recipientUuid;
}
} catch {
// unparsable redirect_uri: fall back to the root page
Expand Down Expand Up @@ -225,6 +243,9 @@ function buildErrorRedirectUrl(
for (const path of sharedPaths) {
params.append(SHARE_DEEP_LINK_PARAM, path);
}
if (shareRecipientUuid) {
params.set(SHARE_RECIPIENT_PARAM, shareRecipientUuid);
}
return `${base}${pagePath}?${params.toString()}`;
}

Expand Down
16 changes: 15 additions & 1 deletion src/backend/services/share/ShareNotificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,20 @@ export class ShareNotificationService extends PuterService {

/** A record's items, or its names alone when it predates the links. */
#recordItems(record: DigestEntryRecord): DigestItem[] {
if (record.items?.length) return record.items;
if (record.items?.length) {
return record.items.map((item) =>
item.path
? {
...item,
link: shareDeepLink(
this.#appLink(),
item.path,
record.recipientUuid,
),
}
: item,
);
}
return (record.names ?? []).map((name) => ({ name }));
}

Expand Down Expand Up @@ -905,6 +918,7 @@ export class ShareNotificationService extends PuterService {
link: sharedViewLink(
this.#appLink(),
digestItemPaths(entries),
first.recipientUuid,
),
// The template composes the unsubscribe URL from
// the origin, so `?` and `=` stay literal instead
Expand Down
26 changes: 26 additions & 0 deletions src/backend/services/share/shareDeepLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ownerFromSharePath,
SHARE_DEEP_LINK_ITEMS_LIMIT,
SHARE_DEEP_LINK_MAX_LENGTH,
SHARE_RECIPIENT_PARAM,
shareDeepLink,
sharedViewLink,
shareTargetLink,
Expand Down Expand Up @@ -98,6 +99,31 @@ describe('shareDeepLink', () => {
});

describe('sharedViewLink', () => {
it('names the account that received the share without changing access', () => {
const recipientUuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
const link = sharedViewLink(
'https://puter.com',
[`/alice/${UID}/a.txt`],
recipientUuid,
);
const params = new URL(link).searchParams;
expect(params.get(SHARE_RECIPIENT_PARAM)).toBe(recipientUuid);
expect(params.get('shared')).toBe(`/alice/${UID}/a.txt`);
});

it('counts the recipient hint toward the email-client length limit', () => {
const paths = Array.from(
{ length: SHARE_DEEP_LINK_ITEMS_LIMIT },
(_, i) => `/alice/${UID}/${'quarterly report '.repeat(8)}${i}.pdf`,
);
const link = sharedViewLink(
'https://puter.com',
paths,
'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
);
expect(link.length).toBeLessThanOrEqual(SHARE_DEEP_LINK_MAX_LENGTH);
});

it('repeats the parameter once per item, in order', () => {
const link = sharedViewLink('https://puter.com', [
`/alice/${UID}/a.txt`,
Expand Down
22 changes: 17 additions & 5 deletions src/backend/services/share/shareDeepLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

/** The query parameter the GUI routes on. */
export const SHARE_DEEP_LINK_PARAM = 'shared';
export const SHARE_RECIPIENT_PARAM = 'user_uuid';

export interface ShareTarget {
/** The entry's own name, which the masked path's last segment must be. */
Expand Down Expand Up @@ -75,12 +76,19 @@ export const SHARE_DEEP_LINK_MAX_LENGTH = 2000;
* the uuid, so a rename is recoverable and there is no second copy to disagree
* with the first. With no paths the link still lands on Shared.
*/
export const sharedViewLink = (origin: string, paths: string[]): string => {
export const sharedViewLink = (
origin: string,
paths: string[],
recipientUuid?: string,
): string => {
const base = `${origin.replace(/\/+$/, '')}/?`;
const recipient = recipientUuid
? `&${SHARE_RECIPIENT_PARAM}=${encodeURIComponent(recipientUuid)}`
: '';
// The first items that fit, in order — never a later one over an
// earlier, so what is highlighted reads as the top of the list.
const params: string[] = [];
let length = base.length;
let length = base.length + recipient.length;
for (const path of new Set(paths)) {
if (params.length === SHARE_DEEP_LINK_ITEMS_LIMIT) break;
const param = `${SHARE_DEEP_LINK_PARAM}=${encodeURIComponent(path)}`;
Expand All @@ -92,13 +100,17 @@ export const sharedViewLink = (origin: string, paths: string[]): string => {
}
return (
base +
(params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&'))
(params.length === 0 ? `${SHARE_DEEP_LINK_PARAM}=` : params.join('&')) +
recipient
);
};

/** A link that opens `path`: the Shared view with that one item highlighted. */
export const shareDeepLink = (origin: string, path: string): string =>
sharedViewLink(origin, [path]);
export const shareDeepLink = (
origin: string,
path: string,
recipientUuid?: string,
): string => sharedViewLink(origin, [path], recipientUuid);

/** The link for a target, or `null` when it isn't addressable. */
export const shareTargetLink = (
Expand Down
7 changes: 4 additions & 3 deletions src/backend/services/share/shareEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ describe('share email', () => {
const confirmed = await post('/confirm-email', token, { code });
expect(await confirmed.json()).toMatchObject({ email_confirmed: true });

return { username, email, token };
const row = await env.server.stores.user.getByUsername(username);
return { username, email, token, uuid: row!.uuid };
};

it('emails an invite to an address with no account', async () => {
Expand Down Expand Up @@ -360,7 +361,7 @@ describe('share email', () => {
expect(openPuterHref(mail.html)).toBe(
`${env.origin}/?shared=${encodeURIComponent(
`/${owner.username}/${first.uid}/${first.name}`,
)}`,
)}&user_uuid=${encodeURIComponent(recipient.uuid)}`,
);
expect(mail.html).toContain(recipient.username);

Expand Down Expand Up @@ -493,7 +494,7 @@ describe('share email', () => {

const mail = await waitForMail({ to: recipient.email });
const masked = `/${owner.username}/${file.uid}/${file.name}`;
const link = `?shared=${encodeURIComponent(masked)}`;
const link = `?shared=${encodeURIComponent(masked)}&user_uuid=${encodeURIComponent(recipient.uuid)}`;
expect(mail.html).toContain(link);
// Linked, not merely mentioned.
expect(mail.html).toContain(`${link}"`);
Expand Down
10 changes: 9 additions & 1 deletion src/gui/src/helpers/authRedirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import parse_shared_path, { SHARED_PATH_PARAM } from './parseSharedPath.js';
import parse_shared_path, {
SHARED_PATH_PARAM,
SHARE_RECIPIENT_PARAM,
sharedLinkRecipientUuid,
} from './parseSharedPath.js';

/**
* Where to send the user after a successful login/signup started from the
Expand Down Expand Up @@ -99,5 +103,9 @@ export const get_oidc_return_to = () => {

const params = new URLSearchParams();
for ( const value of shared ) params.append(SHARED_PATH_PARAM, value);
const recipientUuid = sharedLinkRecipientUuid(
new URLSearchParams(window.location.search ?? ''),
);
if ( recipientUuid ) params.set(SHARE_RECIPIENT_PARAM, recipientUuid);
return `${path}?${params.toString()}`;
};
10 changes: 10 additions & 0 deletions src/gui/src/helpers/authRedirect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ describe('get_oidc_return_to', () => {
).toBe(`/${share_search(shared_path('a.txt'), shared_path('b.txt'))}`);
});

it('carries the intended account through the OIDC round trip', () => {
const search = `${share_search(shared_path('Report.pdf'))}&user_uuid=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee`;
expect(at('/', search)).toBe(`/${search}`);
});

it('drops an invalid intended-account hint', () => {
const shared = share_search(shared_path('Report.pdf'));
expect(at('/', `${shared}&user_uuid=recipient`)).toBe(`/${shared}`);
});

it('leaves behind everything that is not a share link', () => {
// a hand-edited value the backend would refuse anyway
expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null);
Expand Down
Loading