0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

fix(core,schemas): use database index to prevent subdomain conflict (#5326)

This commit is contained in:
wangsijie 2024-01-30 11:53:27 +08:00 committed by GitHub
parent 9f91da075b
commit 1963e12bd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 104 additions and 103 deletions

View file

@ -33,14 +33,13 @@ const { MockQueries } = await import('#src/test-utils/tenant.js');
const { createProtectedAppLibrary } = await import('./protected-app.js');
const findApplicationById = jest.fn(async (): Promise<Application> => mockProtectedApplication);
const findApplicationByProtectedAppHost = jest.fn();
const updateApplicationById = jest.fn(async (id: string, data: Partial<Application>) => ({
...mockProtectedApplication,
...data,
}));
const {
syncAppConfigsToRemote,
checkAndBuildProtectedAppData,
buildProtectedAppData,
syncAppCustomDomainStatus,
getDefaultDomain,
deleteDomainFromRemote,
@ -48,7 +47,6 @@ const {
new MockQueries({
applications: {
findApplicationById,
findApplicationByProtectedAppHost,
updateApplicationById,
},
})
@ -140,11 +138,11 @@ describe('syncAppConfigsToRemote()', () => {
});
});
describe('checkAndBuildProtectedAppData()', () => {
describe('buildProtectedAppData()', () => {
const origin = 'https://example.com';
it('should throw if subdomain is invalid', async () => {
await expect(checkAndBuildProtectedAppData({ subDomain: 'a-', origin })).rejects.toThrowError(
await expect(buildProtectedAppData({ subDomain: 'a-', origin })).rejects.toThrowError(
new RequestError({
code: 'application.invalid_subdomain',
status: 422,
@ -152,20 +150,10 @@ describe('checkAndBuildProtectedAppData()', () => {
);
});
it('should throw if subdomain is not available', async () => {
findApplicationByProtectedAppHost.mockResolvedValueOnce(mockProtectedApplication);
await expect(checkAndBuildProtectedAppData({ subDomain: 'a', origin })).rejects.toThrowError(
new RequestError({
code: 'application.protected_application_subdomain_exists',
status: 422,
})
);
});
it('should return data if subdomain is available', async () => {
const subDomain = 'a';
const host = `${subDomain}.${mockProtectedAppConfigProviderConfig.domain}`;
await expect(checkAndBuildProtectedAppData({ subDomain, origin })).resolves.toEqual({
await expect(buildProtectedAppData({ subDomain, origin })).resolves.toEqual({
protectedAppMetadata: {
host,
origin,

View file

@ -61,6 +61,46 @@ const getDefaultDomain = async () => {
return domain;
};
/**
* Build application data for protected app
* check if subdomain is valid
* generate host based on subdomain
* generate default protectedAppMetadata based on host and origin
* generate redirectUris and postLogoutRedirectUris based on host
*/
const buildProtectedAppData = async ({
subDomain,
origin,
}: {
subDomain: string;
origin: string;
}): Promise<Pick<Application, 'protectedAppMetadata' | 'oidcClientMetadata'>> => {
assertThat(
isValidSubdomain(subDomain),
new RequestError({
code: 'application.invalid_subdomain',
status: 422,
})
);
// Skip for integration test, use empty value instead
const { domain } = EnvSet.values.isIntegrationTest ? { domain: '' } : await getProviderConfig();
const host = `${subDomain}.${domain}`;
return {
protectedAppMetadata: {
host,
origin,
sessionDuration: defaultProtectedAppSessionDuration,
pageRules: defaultProtectedAppPageRules,
},
oidcClientMetadata: {
redirectUris: [`https://${host}/${protectedAppSignInCallbackUrl}`],
postLogoutRedirectUris: [`https://${host}`],
},
};
};
/**
* Call Cloudflare API to add the domain (custom hostname) to the remote
* and get the DNS records to be added to the DNS provider
@ -107,7 +147,7 @@ const deleteDomainFromRemote = async (id: string) => {
export const createProtectedAppLibrary = (queries: Queries) => {
const {
applications: { findApplicationById, findApplicationByProtectedAppHost, updateApplicationById },
applications: { findApplicationById, updateApplicationById },
} = queries;
const syncAppConfigsToRemote = async (applicationId: string): Promise<void> => {
@ -154,56 +194,6 @@ export const createProtectedAppLibrary = (queries: Queries) => {
}
};
/**
* Build application data for protected app
* check if subdomain is valid
* generate host based on subdomain
* generate default protectedAppMetadata based on host and origin
* generate redirectUris and postLogoutRedirectUris based on host
*/
const checkAndBuildProtectedAppData = async ({
subDomain,
origin,
}: {
subDomain: string;
origin: string;
}): Promise<Pick<Application, 'protectedAppMetadata' | 'oidcClientMetadata'>> => {
assertThat(
isValidSubdomain(subDomain),
new RequestError({
code: 'application.invalid_subdomain',
status: 422,
})
);
// Skip for integration test, use empty value instead
const { domain } = EnvSet.values.isIntegrationTest ? { domain: '' } : await getProviderConfig();
const host = `${subDomain}.${domain}`;
const application = await findApplicationByProtectedAppHost(host);
assertThat(
!application,
new RequestError({
code: 'application.protected_application_subdomain_exists',
status: 422,
})
);
return {
protectedAppMetadata: {
host,
origin,
sessionDuration: defaultProtectedAppSessionDuration,
pageRules: defaultProtectedAppPageRules,
},
oidcClientMetadata: {
redirectUris: [`https://${host}/${protectedAppSignInCallbackUrl}`],
postLogoutRedirectUris: [`https://${host}`],
},
};
};
/**
* Query domain status from Cloudflare and update the data and status in the database
*/
@ -280,7 +270,7 @@ export const createProtectedAppLibrary = (queries: Queries) => {
return {
syncAppConfigsToRemote,
deleteRemoteAppConfigs,
checkAndBuildProtectedAppData,
buildProtectedAppData,
getDefaultDomain,
addDomainToRemote,
syncAppCustomDomainStatus,

View file

@ -1,5 +1,5 @@
import { Users } from '@logto/schemas';
import { NotFoundError, SlonikError } from 'slonik';
import { NotFoundError, SlonikError, UniqueIntegrityConstraintViolationError } from 'slonik';
import RequestError from '#src/errors/RequestError/index.js';
import { DeletionError, InsertionError, UpdateError } from '#src/errors/SlonikError/index.js';
@ -98,4 +98,21 @@ describe('koaSlonikErrorHandler middleware', () => {
})
);
});
it('UniqueIntegrityConstraintViolationError for protected application', async () => {
const error = new UniqueIntegrityConstraintViolationError(
new Error(' '),
'applications__protected_app_metadata_host'
);
next.mockImplementationOnce(() => {
throw error;
});
await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError(
new RequestError({
code: 'application.protected_application_subdomain_exists',
status: 422,
})
);
});
});

View file

@ -5,6 +5,7 @@ import {
NotFoundError,
InvalidInputError,
CheckIntegrityConstraintViolationError,
UniqueIntegrityConstraintViolationError,
} from 'slonik';
import RequestError from '#src/errors/RequestError/index.js';
@ -26,6 +27,16 @@ export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<St
});
}
if (
error instanceof UniqueIntegrityConstraintViolationError &&
error.constraint === 'applications__protected_app_metadata_host'
) {
throw new RequestError({
code: 'application.protected_application_subdomain_exists',
status: 422,
});
}
if (error instanceof CheckIntegrityConstraintViolationError) {
throw new RequestError({
code: 'entity.db_constraint_violated',

View file

@ -22,7 +22,6 @@ const { createApplicationQueries } = await import('./application.js');
const {
findTotalNumberOfApplications,
findApplicationById,
findApplicationByProtectedAppHost,
findApplicationByProtectedAppCustomDomain,
insertApplication,
updateApplicationById,
@ -68,27 +67,6 @@ describe('application query', () => {
await findApplicationById(id);
});
it('findApplicationByProtectedAppHost', async () => {
const host = 'host.protected.app';
const rowData = { host };
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.protectedAppMetadata}->>'host' = $1
and ${fields.type} = $2
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([host, ApplicationType.Protected]);
return createMockQueryResult([rowData]);
});
await findApplicationByProtectedAppHost(host);
});
it('findApplicationByProtectedAppCustomDomain', async () => {
const domain = 'my.blog.com';
const rowData = { domain };

View file

@ -130,14 +130,6 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications);
const findApplicationByProtectedAppHost = async (host: string) =>
pool.maybeOne<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.protectedAppMetadata}->>'host' = ${host}
and ${fields.type} = ${ApplicationType.Protected}
`);
/**
* Find an protected application by its custom domain.
* the domain is stored in the `customDomains` field of the `protectedAppMetadata` field.
@ -252,7 +244,6 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
findApplications,
findTotalNumberOfApplications,
findApplicationById,
findApplicationByProtectedAppHost,
findApplicationByProtectedAppCustomDomain,
insertApplication,
updateApplication,

View file

@ -19,7 +19,7 @@ const findApplicationById = jest.fn(async () => mockApplication);
const deleteApplicationById = jest.fn();
const syncAppConfigsToRemote = jest.fn();
const deleteRemoteAppConfigs = jest.fn();
const checkAndBuildProtectedAppData = jest.fn(async () => {
const buildProtectedAppData = jest.fn(async () => {
const { oidcClientMetadata, protectedAppMetadata } = mockProtectedApplication;
return { oidcClientMetadata, protectedAppMetadata };
@ -60,7 +60,7 @@ const tenantContext = new MockTenant(
protectedApps: {
syncAppConfigsToRemote,
deleteRemoteAppConfigs,
checkAndBuildProtectedAppData,
buildProtectedAppData,
getDefaultDomain: jest.fn(async () => mockProtectedAppConfigProviderConfig.domain),
},
}

View file

@ -173,7 +173,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
...conditional(
rest.type === ApplicationType.Protected &&
protectedAppMetadata &&
(await protectedApps.checkAndBuildProtectedAppData(protectedAppMetadata))
(await protectedApps.buildProtectedAppData(protectedAppMetadata))
),
...rest,
});

View file

@ -0,0 +1,21 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
create unique index applications__protected_app_metadata_host
on applications (
(protected_app_metadata->>'host')
);
`);
},
down: async (pool) => {
await pool.query(sql`
drop index applications__protected_app_metadata_host;
`);
},
};
export default alteration;

View file

@ -23,3 +23,8 @@ create index applications__id
create index applications__is_third_party
on applications (tenant_id, is_third_party);
create unique index applications__protected_app_metadata_host
on applications (
(protected_app_metadata->>'host')
);