feat: Security
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user