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
16 changes: 16 additions & 0 deletions .changeset/wild-cats-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@logto/console": minor
"@logto/core": minor
"@logto/core-kit": minor
"@logto/phrases": minor
"@logto/schemas": minor
---

support wildcard patterns in redirect URIs

Added support for wildcard patterns (`*`) in redirect URIs to better support dynamic environments like preview deployments.

Rules (web only):
- Wildcards are allowed for http/https redirect URIs in the hostname and/or pathname.
- Wildcards are rejected in scheme, port, query, and hash.
- Hostname wildcard patterns must contain at least one dot to avoid overly broad patterns.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
}
},
"patchedDependencies": {
"tsup": "patches/tsup.patch"
"tsup": "patches/tsup.patch",
"oidc-provider": "patches/oidc-provider.patch"
}
},
"dependencies": {
Expand Down
8 changes: 6 additions & 2 deletions packages/console/src/mdx-components/UriInputField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
OidcClientMetadataKey,
} from '@/types/guide';
import { trySubmitSafe } from '@/utils/form';
import { uriValidator } from '@/utils/validator';
import { redirectUriValidator, uriValidator } from '@/utils/validator';

import styles from './index.module.scss';

Expand Down Expand Up @@ -68,6 +68,10 @@ function UriInputField(props: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useApi();
const title: AdminConsoleKey = nameToKey[name];
const validator =
name === 'redirectUris' || name === 'postLogoutRedirectUris'
? redirectUriValidator
: uriValidator;

const onSubmit = trySubmitSafe(async (value: string[]) => {
if (!appId || !data) {
Expand Down Expand Up @@ -117,7 +121,7 @@ function UriInputField(props: Props) {
validate: createValidatorForRhf({
required: t('errors.required_field_missing_plural', { field: t(title) }),
pattern: {
verify: (value) => !value || uriValidator(value),
verify: (value) => !value || validator(value),
message: t('errors.invalid_uri_format'),
},
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ const hasMixedUriProtocols = (applicationType: ApplicationType, uris: string[]):
}
};

const hasWildcardUri = (uris?: string[]) => Boolean(uris?.some((uri) => uri.includes('*')));

/**
* Validates redirect URIs based on application type.
* Wildcards are only allowed for web applications (SPA and Traditional), not for native apps.
*/
const createRedirectUriValidator = (applicationType: ApplicationType) => (value: string) => {
// Native apps don't support wildcard redirect URIs
if (applicationType === ApplicationType.Native && value.includes('*')) {
return false;
}
return validateRedirectUrl(value, 'web') || validateRedirectUrl(value, 'mobile');
};

function MixedUriWarning() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
Expand All @@ -47,6 +61,15 @@ function MixedUriWarning() {
);
}

function WildcardUriWarning() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<InlineNotification severity="alert" className={styles.mixedUriWarning}>
{t('application_details.wildcard_redirect_uri_warning')}
</InlineNotification>
);
}

type Props = {
readonly data: Application;
};
Expand All @@ -63,10 +86,10 @@ function Settings({ data }: Props) {
const { type: applicationType, isThirdParty } = data;

const isProtectedApp = applicationType === ApplicationType.Protected;
const redirectUriValidator = createRedirectUriValidator(applicationType);
const uriPatternRules: MultiTextInputRule = {
pattern: {
verify: (value) =>
!value || validateRedirectUrl(value, 'web') || validateRedirectUrl(value, 'mobile'),
verify: (value) => !value || redirectUriValidator(value),
message: t('errors.invalid_uri_format'),
},
};
Expand All @@ -77,6 +100,8 @@ function Settings({ data }: Props) {
applicationType,
postLogoutRedirectUris
);
const showRedirectUriWildcardWarning = hasWildcardUri(redirectUris);
const showPostLogoutUriWildcardWarning = hasWildcardUri(postLogoutRedirectUris);

if (isProtectedApp) {
return <ProtectedAppSettings data={data} />;
Expand Down Expand Up @@ -143,6 +168,7 @@ function Settings({ data }: Props) {
)}
/>
)}
{showRedirectUriWildcardWarning && <WildcardUriWarning />}
{showRedirectUriMixedWarning && <MixedUriWarning />}
{applicationType !== ApplicationType.MachineToMachine && (
<Controller
Expand All @@ -164,6 +190,7 @@ function Settings({ data }: Props) {
)}
/>
)}
{showPostLogoutUriWildcardWarning && <WildcardUriWarning />}
{showPostLogoutUriMixedWarning && <MixedUriWarning />}
{applicationType !== ApplicationType.MachineToMachine && (
<Controller
Expand Down
100 changes: 100 additions & 0 deletions packages/console/src/utils/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,106 @@ export const uriValidator = (value: string) => {
return true;
};

/**
* Checks if the path contains dot segments (.., ., %2e, %2e%2e) that could be used
* for path traversal attacks.
*/
const hasDotSegmentsInPath = (url: string, schemeSeparatorIndex: number): boolean => {
const authority = url.slice(schemeSeparatorIndex + 3).split(/[#/?]/)[0] ?? '';
const afterAuthorityIndex = schemeSeparatorIndex + 3 + authority.length;
const rest = url.slice(afterAuthorityIndex);
const path = rest.split(/[#?]/)[0] ?? '';

if (!path) {
return false;
}

const segments = path.split('/');
return segments.some((segment) => {
const normalized = segment.toLowerCase();
return segment === '.' || segment === '..' || normalized === '%2e' || normalized === '%2e%2e';
});
};

const hasWildcardInQueryOrHash = (url: string): boolean => {
const queryIndex = url.indexOf('?');
if (queryIndex >= 0 && url.slice(queryIndex).includes('*')) {
return true;
}

const hashIndex = url.indexOf('#');
return hashIndex >= 0 && url.slice(hashIndex).includes('*');
};

const isValidWildcardAuthority = (authority: string): boolean => {
// Disallow credentials and IPv6 literals for simplicity.
if (!authority || authority.includes('@') || authority.startsWith('[')) {
return false;
}

// Disallow wildcards in port segment.
const lastColonIndex = authority.lastIndexOf(':');
const hasPort = lastColonIndex > -1 && authority.indexOf(':') === lastColonIndex;
const hostname = hasPort ? authority.slice(0, lastColonIndex) : authority;

// When wildcard is used in hostname, require at least one dot to avoid overly broad patterns.
if (hostname.includes('*') && !hostname.includes('.')) {
return false;
}

if (hasPort && authority.slice(lastColonIndex + 1).includes('*')) {
return false;
}

return true;
};

const isValidWildcardUrl = (value: string): boolean => {
try {
// eslint-disable-next-line no-new
new URL(value.replaceAll('*', 'wildcard'));
return true;
} catch {
return false;
}
};

export const redirectUriValidator = (value: string) => {
if (!value.includes('*')) {
return uriValidator(value);
}

const schemeSeparatorIndex = value.indexOf('://');
if (schemeSeparatorIndex <= 0) {
return false;
}

// Disallow wildcards in scheme (check before scheme validation).
if (value.slice(0, schemeSeparatorIndex).includes('*')) {
return false;
}

const scheme = value.slice(0, schemeSeparatorIndex).toLowerCase();
if (scheme !== 'http' && scheme !== 'https') {
return false;
}

if (hasWildcardInQueryOrHash(value)) {
return false;
}

const authority = value.slice(schemeSeparatorIndex + 3).split(/[#/?]/)[0] ?? '';
if (!isValidWildcardAuthority(authority)) {
return false;
}

if (hasDotSegmentsInPath(value, schemeSeparatorIndex)) {
return false;
}

return isValidWildcardUrl(value);
};

export const jsonValidator = (value: string) => {
try {
JSON.parse(value);
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/oidc/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,107 @@ describe('isOriginAllowed', () => {
)
).toBeTruthy();
});

it('should return true if redirectUris include a wildcard pattern that matches the origin', () => {
expect(
isOriginAllowed(
'https://pr-123.myapp.example.com',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
['https://*.myapp.example.com/callback']
)
).toBeTruthy();
});

it('should return false if wildcard pattern does not match the origin (no subdomain)', () => {
// *.example.com should NOT match example.com (requires at least one subdomain label)
expect(
isOriginAllowed(
'https://example.com',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
['https://*.example.com/callback']
)
).toBeFalsy();
});

it('should handle port matching with wildcards correctly', () => {
// Same port should match
expect(
isOriginAllowed(
'https://pr-123.example.com:8080',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
['https://*.example.com:8080/callback']
)
).toBeTruthy();

// Different port should not match
expect(
isOriginAllowed(
'https://pr-123.example.com:3000',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
['https://*.example.com:8080/callback']
)
).toBeFalsy();

// Default port (443 for https) should match when not specified
expect(
isOriginAllowed(
'https://pr-123.example.com',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
['https://*.example.com/callback']
)
).toBeTruthy();
});

it('should return false for protocol mismatch', () => {
expect(
isOriginAllowed(
'http://pr-123.example.com',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
['https://*.example.com/callback']
)
).toBeFalsy();
});

it('should return true when only one of multiple wildcard patterns matches', () => {
expect(
isOriginAllowed(
'https://pr-123.myapp.example.com',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
[
'https://*.other-domain.com/callback',
'https://*.myapp.example.com/callback',
'https://*.another.com/callback',
]
)
).toBeTruthy();
});

it('should handle malformed wildcard patterns gracefully', () => {
// Invalid/malformed patterns should not crash and should return false
expect(
isOriginAllowed(
'https://example.com',
{
[CustomClientMetadataKey.CorsAllowedOrigins]: [],
},
['not-a-valid-url', 'https://*.example.com/callback']
)
).toBeFalsy();
});
});

describe('buildLoginPromptUrl', () => {
Expand Down
Loading
Loading