feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -91,7 +91,7 @@
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ 'dashboard.openInvite' | translate }}</p>
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
<p class="truncate text-xs text-muted-foreground">{{ invite.inviteId }}</p>
</div>
<ng-icon
name="lucideArrowRight"

View File

@@ -154,7 +154,7 @@ describe('DashboardComponent', () => {
component.onSearchChange('https://app.test/invite/Code_42');
expect(component.inviteResult()).toBe('Code_42');
expect(component.inviteResult()).toEqual({ inviteId: 'Code_42' });
});
it('opens a joined server in place and routes others to the servers page', () => {
@@ -174,7 +174,18 @@ describe('DashboardComponent', () => {
component.onSearchChange('abc123');
component.openInvite();
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'abc123']);
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'abc123'], { queryParams: undefined });
});
it('forwards the signal server when opening a full invite URL', () => {
const { component, router } = createHarness();
component.onSearchChange('https://web.toju.app/invite/Code_42?server=https%3A%2F%2Fsignal.toju.app');
component.openInvite();
expect(router.navigate).toHaveBeenCalledWith(['/invite', 'Code_42'], {
queryParams: { server: 'https://signal.toju.app' }
});
});
it('suggests people you might know independent of the query, excluding self', () => {

View File

@@ -260,9 +260,13 @@ export class DashboardComponent implements OnInit {
openInvite(): void {
const invite = this.inviteResult();
if (invite) {
this.router.navigate(['/invite', invite]);
if (!invite) {
return;
}
this.router.navigate(['/invite', invite.inviteId], {
queryParams: invite.sourceUrl ? { server: invite.sourceUrl } : undefined
});
}
serverInitial(server: ServerInfo): string {

View File

@@ -16,16 +16,37 @@ describe('parseInviteQuery', () => {
expect(parseInviteQuery('hello world')).toBeNull();
});
it('treats a bare url-safe code as an invite', () => {
expect(parseInviteQuery('abc123')).toBe('abc123');
expect(parseInviteQuery('Team-Code_9')).toBe('Team-Code_9');
it('treats a bare url-safe code as an invite without server context', () => {
expect(parseInviteQuery('abc123')).toEqual({ inviteId: 'abc123' });
expect(parseInviteQuery('Team-Code_9')).toEqual({ inviteId: 'Team-Code_9' });
});
it('extracts the id from an invite path', () => {
expect(parseInviteQuery('/invite/xyz789')).toBe('xyz789');
expect(parseInviteQuery('/invite/xyz789')).toEqual({ inviteId: 'xyz789' });
});
it('extracts the id from a full invite URL', () => {
expect(parseInviteQuery('https://app.test/invite/Code_42?ref=1')).toBe('Code_42');
it('extracts the id and server from a browser invite URL', () => {
expect(parseInviteQuery('https://web.toju.app/invite/Code_42?server=https%3A%2F%2Fsignal.toju.app')).toEqual({
inviteId: 'Code_42',
sourceUrl: 'https://signal.toju.app'
});
});
it('derives the signal server from a signal-origin invite URL', () => {
expect(parseInviteQuery('https://localhost:3001/invite/abc123')).toEqual({
inviteId: 'abc123',
sourceUrl: 'https://localhost:3001'
});
});
it('does not treat a web-app invite URL without server param as complete', () => {
expect(parseInviteQuery('https://app.test/invite/Code_42?ref=1')).toEqual({ inviteId: 'Code_42' });
});
it('parses toju protocol invite links', () => {
expect(parseInviteQuery('toju://invite/DeepLink_1?server=https%3A%2F%2Fsignal.toju.app')).toEqual({
inviteId: 'DeepLink_1',
sourceUrl: 'https://signal.toju.app'
});
});
});

View File

@@ -1,29 +1,96 @@
export interface ParsedInviteQuery {
inviteId: string;
sourceUrl?: string;
}
/**
* Parses a dashboard search query into an invite identifier when it looks like an
* invite code or an invite URL. Returns `null` when the query is not invite-like.
* Parses a dashboard search query into invite context when it looks like an invite
* code or URL. Returns `null` when the query is not invite-like.
*
* Accepted shapes:
* - A bare code: `abc123`, `Team-Code_9` (6+ url-safe chars, no whitespace)
* - A path containing `/invite/<id>`
* - A full URL whose path contains `/invite/<id>`
* - A path or URL containing `/invite/<id>`
* - Browser invite URLs with `?server=<signal-origin>`
* - Signal-server invite URLs where the origin is the signal server
* - `toju://invite/<id>?server=<signal-origin>`
*/
export function parseInviteQuery(rawQuery: string): string | null {
export function parseInviteQuery(rawQuery: string): ParsedInviteQuery | null {
const query = rawQuery.trim();
if (query.length === 0) {
return null;
}
if (query.startsWith('toju:')) {
const protocolInvite = parseTojuInviteUrl(query);
if (protocolInvite) {
return protocolInvite;
}
}
const invitePathMatch = /\/invite\/([A-Za-z0-9_-]+)/.exec(query);
if (invitePathMatch) {
return invitePathMatch[1];
return {
inviteId: invitePathMatch[1],
sourceUrl: deriveSourceUrlFromInviteReference(query)
};
}
// A bare invite code: url-safe characters only, no whitespace, reasonably long.
if (/^[A-Za-z0-9_-]{6,}$/.test(query)) {
return query;
return { inviteId: query };
}
return null;
}
function parseTojuInviteUrl(url: string): ParsedInviteQuery | null {
try {
const parsedUrl = new URL(url);
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
.map((segment) => decodeURIComponent(segment));
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
return null;
}
return {
inviteId: pathSegments[1],
sourceUrl: parsedUrl.searchParams.get('server')?.trim() || undefined
};
} catch {
return null;
}
}
function deriveSourceUrlFromInviteReference(reference: string): string | undefined {
try {
const normalizedReference = reference.includes('://')
? reference
: `https://placeholder.test${reference.startsWith('/') ? reference : `/${reference}`}`;
const parsed = new URL(normalizedReference);
const serverParam = parsed.searchParams.get('server')?.trim();
if (serverParam) {
return serverParam;
}
if (parsed.hostname === 'placeholder.test') {
return undefined;
}
if (isLikelySignalServerOrigin(parsed)) {
return `${parsed.protocol}//${parsed.host}`;
}
return undefined;
} catch {
return undefined;
}
}
function isLikelySignalServerOrigin(url: URL): boolean {
return url.hostname.startsWith('signal.') || url.port === '3001';
}

View File

@@ -284,14 +284,13 @@ export class TitleBarComponent {
const invite = await firstValueFrom(this.serverDirectory.createInvite(
room.id,
{
requesterUserId: user.id,
requesterDisplayName: user.displayName,
requesterRole: user.role
},
this.toSourceSelector(room)
));
await this.copyInviteLink(invite.inviteUrl);
await this.copyInviteLink(invite.browserUrl);
this.inviteStatus.set(this.appI18n.instant('shell.titleBar.inviteCopied'));
} catch (error: unknown) {
const inviteError = error as { error?: { error?: string } };