fix: use team.findMany for fetching user teams (#6057)

This commit is contained in:
Mir Arif Hasan 2026-03-28 08:37:10 +06:00 committed by GitHub
parent d5a19320b8
commit 8ac1b29b88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 79 additions and 66 deletions

View file

@ -3,65 +3,95 @@ import { isValidLocalhostRedirectUri } from './redirect-uri.validator';
describe('isValidLocalhostRedirectUri', () => {
describe('valid loopback URIs', () => {
test('Should return true for http://localhost with a port', () => {
expect(isValidLocalhostRedirectUri('http://localhost:3000/device-token')).toBe(true);
expect(
isValidLocalhostRedirectUri('http://localhost:3000/device-token'),
).toBe(true);
});
test('Should return true for http://localhost without a port', () => {
expect(isValidLocalhostRedirectUri('http://localhost/callback')).toBe(true);
expect(isValidLocalhostRedirectUri('http://localhost/callback')).toBe(
true,
);
});
test('Should return true for http://127.0.0.1 with a port', () => {
expect(isValidLocalhostRedirectUri('http://127.0.0.1:8080/callback')).toBe(true);
expect(
isValidLocalhostRedirectUri('http://127.0.0.1:8080/callback'),
).toBe(true);
});
test('Should return true for http://[::1] IPv6 loopback', () => {
expect(isValidLocalhostRedirectUri('http://[::1]:9090/callback')).toBe(true);
expect(isValidLocalhostRedirectUri('http://[::1]:9090/callback')).toBe(
true,
);
});
});
describe('DNS wildcard bypass vectors', () => {
test('Should reject localhost subdomain via sslip.io wildcard DNS', () => {
expect(isValidLocalhostRedirectUri('http://localhost.1.2.3.4.sslip.io:3000/steal')).toBe(false);
expect(
isValidLocalhostRedirectUri(
'http://localhost.1.2.3.4.sslip.io:3000/steal',
),
).toBe(false);
});
test('Should reject localhost subdomain via nip.io wildcard DNS', () => {
expect(isValidLocalhostRedirectUri('http://localhost.10.0.0.1.nip.io/steal')).toBe(false);
expect(
isValidLocalhostRedirectUri('http://localhost.10.0.0.1.nip.io/steal'),
).toBe(false);
});
test('Should reject localhost as a subdomain of attacker domain', () => {
expect(isValidLocalhostRedirectUri('http://localhost.evil.com/steal')).toBe(false);
expect(
isValidLocalhostRedirectUri('http://localhost.evil.com/steal'),
).toBe(false);
});
test('Should reject localhost with trailing dot FQDN trick', () => {
expect(isValidLocalhostRedirectUri('http://localhost./callback')).toBe(false);
expect(isValidLocalhostRedirectUri('http://localhost./callback')).toBe(
false,
);
});
test('Should reject domain that starts with localhost string', () => {
expect(isValidLocalhostRedirectUri('http://localhostevil.com/callback')).toBe(false);
expect(
isValidLocalhostRedirectUri('http://localhostevil.com/callback'),
).toBe(false);
});
});
describe('protocol enforcement', () => {
test('Should reject https since loopback listeners do not serve TLS', () => {
expect(isValidLocalhostRedirectUri('https://localhost:3000/callback')).toBe(false);
expect(
isValidLocalhostRedirectUri('https://localhost:3000/callback'),
).toBe(false);
});
test('Should reject ftp protocol', () => {
expect(isValidLocalhostRedirectUri('ftp://localhost/callback')).toBe(false);
expect(isValidLocalhostRedirectUri('ftp://localhost/callback')).toBe(
false,
);
});
});
describe('credential and remote host rejection', () => {
test('Should reject URL with embedded credentials', () => {
expect(isValidLocalhostRedirectUri('http://user:pass@localhost:3000/callback')).toBe(false);
expect(
isValidLocalhostRedirectUri('http://user:pass@localhost:3000/callback'),
).toBe(false);
});
test('Should reject an arbitrary remote host', () => {
expect(isValidLocalhostRedirectUri('http://attacker.com:3000/callback')).toBe(false);
expect(
isValidLocalhostRedirectUri('http://attacker.com:3000/callback'),
).toBe(false);
});
test('Should reject 0.0.0.0 since it is not a loopback address', () => {
expect(isValidLocalhostRedirectUri('http://0.0.0.0:3000/callback')).toBe(false);
expect(isValidLocalhostRedirectUri('http://0.0.0.0:3000/callback')).toBe(
false,
);
});
});

View file

@ -1,7 +1,9 @@
// Only true loopback addresses are safe for native app redirects
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
export function isValidLocalhostRedirectUri(uri: string | undefined | null): boolean {
export function isValidLocalhostRedirectUri(
uri: string | undefined | null,
): boolean {
if (!uri) return false;
let url: URL;

View file

@ -177,10 +177,7 @@ export class MockServerController {
// Security headers to prevent XSS via mock responses
res.setHeader('X-Content-Type-Options', 'nosniff');
if (!isSubdomainAccess) {
res.setHeader(
'Content-Security-Policy',
"default-src 'none'; sandbox",
);
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.setHeader('X-Frame-Options', 'DENY');
}

View file

@ -800,17 +800,20 @@ describe('getMembersOfTeam', () => {
describe('getTeamsOfUser', () => {
test('resolves with the first 10 elements when no cursor is given', async () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
mockPrisma.team.findMany.mockResolvedValue([]);
await teamService.getTeamsOfUser(dbTeamMember.userUid, null);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
expect(mockPrisma.team.findMany).toHaveBeenCalledWith({
take: 10,
skip: 0,
cursor: undefined,
where: {
userUid: dbTeamMember.userUid,
},
include: {
team: true,
members: {
some: {
userUid: dbTeamMember.userUid,
},
},
},
});
});
@ -818,30 +821,28 @@ describe('getTeamsOfUser', () => {
test('resolves as expected for paginated requests with cursor', async () => {
const cursor = 'secondpage';
mockPrisma.teamMember.findMany.mockResolvedValue([]);
mockPrisma.team.findMany.mockResolvedValue([]);
await teamService.getTeamsOfUser(dbTeamMember.userUid, cursor);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
expect(mockPrisma.team.findMany).toHaveBeenCalledWith({
take: 10,
skip: 1,
cursor: {
teamID_userUid: {
teamID: cursor,
userUid: dbTeamMember.userUid,
},
id: cursor,
},
where: {
userUid: dbTeamMember.userUid,
},
include: {
team: true,
members: {
some: {
userUid: dbTeamMember.userUid,
},
},
},
});
});
test('resolves with an empty array for an invalid cursor', () => {
// Invalid cursors return an empty array
mockPrisma.teamMember.findMany.mockResolvedValue([]);
mockPrisma.team.findMany.mockResolvedValue([]);
return expect(
teamService.getTeamsOfUser(dbTeamMember.userUid, 'invalidcursor'),
@ -849,7 +850,7 @@ describe('getTeamsOfUser', () => {
});
test('resolves with an empty array for invalid user id and null cursor', () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
mockPrisma.team.findMany.mockResolvedValue([]);
return expect(
teamService.getTeamsOfUser('invalidid', null),
@ -857,7 +858,7 @@ describe('getTeamsOfUser', () => {
});
test('resolves with an empty array for invalid user id and invalid cursor', () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
mockPrisma.team.findMany.mockResolvedValue([]);
return expect(
teamService.getTeamsOfUser('invalidId', 'invalidCursor'),

View file

@ -266,37 +266,20 @@ export class TeamService implements UserDataHandler, OnModuleInit {
cursor: string | null,
take = 10,
): Promise<Team[]> {
if (!cursor) {
const entries = await this.prisma.teamMember.findMany({
take,
where: {
userUid: uid,
},
include: {
team: true,
},
});
return entries.map((entry) => entry.team);
} else {
const entries = await this.prisma.teamMember.findMany({
take,
skip: 1,
cursor: {
teamID_userUid: {
teamID: cursor,
const teams = await this.prisma.team.findMany({
take,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
where: {
members: {
some: {
userUid: uid,
},
},
where: {
userUid: uid,
},
include: {
team: true,
},
});
return entries.map((entry) => entry.team);
}
},
});
return teams;
}
async getTeamWithID(teamID: string): Promise<Team | null> {