Skip to content

Commit

Permalink
Update nextUrl validation to incorporate serverBasePath (opensearch-p…
Browse files Browse the repository at this point in the history
…roject#2048) (opensearch-project#2050)

(cherry picked from commit fc4f6a2)
  • Loading branch information
Crispy-fried-chicken authored Aug 24, 2024
1 parent 2a11d1d commit 9513c5e
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 27 deletions.
12 changes: 11 additions & 1 deletion server/auth/types/basic/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ export class BasicAuthRoutes {
this.coreSetup.http.resources.register(
{
path: LOGIN_PAGE_URI,
validate: false,
validate: {
query: schema.object({
nextUrl: schema.maybe(
schema.string({
validate: (nexturl) => {
return validateNextUrl(nexturl, this.coreSetup.http.basePath.serverBasePath);
},
})
),
}),
},
options: {
authRequired: false,
},
Expand Down
8 changes: 6 additions & 2 deletions server/auth/types/openid/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ export class OpenIdAuthRoutes {
code: schema.maybe(schema.string()),
nextUrl: schema.maybe(
schema.string({
validate: validateNextUrl,
validate: (nexturl) => {
return validateNextUrl(nexturl, this.core.http.basePath.serverBasePath);
},
})
),
redirectHash: schema.maybe(schema.boolean()),
Expand Down Expand Up @@ -293,7 +295,9 @@ export class OpenIdAuthRoutes {
query: schema.object({
nextUrl: schema.maybe(
schema.string({
validate: validateNextUrl,
validate: (nexturl) => {
return validateNextUrl(nexturl, this.core.http.basePath.serverBasePath);
},
})
),
}),
Expand Down
4 changes: 3 additions & 1 deletion server/auth/types/proxy/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export class ProxyAuthRoutes {
query: schema.object({
nextUrl: schema.maybe(
schema.string({
validate: validateNextUrl,
validate: (nexturl) => {
return validateNextUrl(nexturl, this.coreSetup.http.basePath.serverBasePath);
},
})
),
}),
Expand Down
8 changes: 6 additions & 2 deletions server/auth/types/saml/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export class SamlAuthRoutes {
query: schema.object({
nextUrl: schema.maybe(
schema.string({
validate: validateNextUrl,
validate: (nexturl) => {
return validateNextUrl(nexturl, this.coreSetup.http.basePath.serverBasePath);
},
})
),
redirectHash: schema.string(),
Expand Down Expand Up @@ -270,7 +272,9 @@ export class SamlAuthRoutes {
query: schema.object({
nextUrl: schema.maybe(
schema.string({
validate: validateNextUrl,
validate: (nexturl) => {
return validateNextUrl(nexturl, this.coreSetup.http.basePath.serverBasePath);
},
})
),
}),
Expand Down
33 changes: 23 additions & 10 deletions server/utils/next_url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,48 +69,54 @@ describe('test composeNextUrlQueryParam', () => {
describe('test validateNextUrl', () => {
test('accept relative url', () => {
const url = '/relative/path';
expect(validateNextUrl(url)).toEqual(undefined);
expect(validateNextUrl(url, '')).toEqual(undefined);
});

test('accept relative url with # and query', () => {
const url = '/relative/path#hash?a=b';
expect(validateNextUrl(url)).toEqual(undefined);
expect(validateNextUrl(url, undefined)).toEqual(undefined);
});

test('reject url not start with /', () => {
const url = 'exmaple.com/relative/path';
expect(validateNextUrl(url)).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
expect(validateNextUrl(url, '')).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
});

test('reject absolute url', () => {
const url = 'https://exmaple.com/relative/path';
expect(validateNextUrl(url)).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
expect(validateNextUrl(url, '')).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
});

test('reject url starts with //', () => {
const url = '//exmaple.com/relative/path';
expect(validateNextUrl(url)).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
expect(validateNextUrl(url, '')).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
});

test('accpet url has @ in query parameters', () => {
const url = '/test/path?key=a@b&k2=v';
expect(validateNextUrl(url)).toEqual(undefined);
expect(validateNextUrl(url, '')).toEqual(undefined);
});

test('allow slash', () => {
const url = '/';
expect(validateNextUrl(url)).toEqual(undefined);
expect(validateNextUrl(url, '')).toEqual(undefined);
});

test('allow dashboard url', () => {
const url =
'/_plugin/opensearch-dashboards/app/opensearch-dashboards#dashbard/dashboard-id?_g=(param=a&p=b)';
expect(validateNextUrl(url)).toEqual(undefined);
expect(validateNextUrl(url, '')).toEqual(undefined);
});

test('allow basePath with numbers', () => {
const url = '/123/app/dashboards';
expect(validateNextUrl(url, '/123')).toEqual(undefined);
});

// test cases from https://pentester.land/cheatsheets/2018/11/02/open-redirect-cheatsheet.html
test('test list', () => {
const urlList = [
'/\t/example.com/',
'<>//Ⓛ𝐨𝗰�𝕝ⅆ𝓸ⓜₐℹⓃ。Pⓦ',
'//;@Ⓛ𝐨𝗰�𝕝ⅆ𝓸ⓜₐℹⓃ。Pⓦ',
'/////Ⓛ𝐨𝗰�𝕝ⅆ𝓸ⓜₐℹⓃ。Pⓦ/',
Expand Down Expand Up @@ -624,10 +630,17 @@ describe('test validateNextUrl', () => {
'//XY>.7d8T\\[email protected]+@Ⓛ𝐨𝗰�𝕝ⅆ𝓸ⓜₐℹⓃ。Pⓦ/',
'//XY>.7d8T\\[email protected]@google.com/',
'//XY>.7d8T\\[email protected][email protected]/',
'javascript://sub.domain.com/%0Aalert(1)',
'javascript://%250Aalert(1)',
'javascript://%250Aalert(1)//?1',
'javascript://%250A1?alert(1):0',
'%09Jav%09ascript:alert(document.domain)',
'javascript://%250Alert(document.location=document.cookie)',
'\\j\\av\\a\\s\\cr\\i\\pt\\:\\a\\l\\ert\\(1\\)',
];
for (const url in urlList) {
for (const url of urlList) {
if (url) {
expect(validateNextUrl(url)).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
expect(validateNextUrl(url, '')).toEqual(INVALID_NEXT_URL_PARAMETER_MESSAGE);
}
}
});
Expand Down
28 changes: 17 additions & 11 deletions server/utils/next_url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,25 +45,31 @@ export const INVALID_NEXT_URL_PARAMETER_MESSAGE = 'Invalid nextUrl parameter.';
/**
* We require the nextUrl parameter to be an relative url.
*
* Here we leverage the normalizeUrl function. If the library can parse the url
* parameter, which means it is an absolute url, then we reject it. Otherwise, the
* library cannot parse the url, which means it is not an absolute url, we let to
* go through.
* Here we validate the nextUrl parameter by checking if it meets the following criteria:
* - nextUrl starts with the basePath (/ if no serverBasePath is set)
* - If nextUrl is longer than 2 chars then the second character must be alphabetical or underscore
* - The following characters must be alphanumeric, dash or underscore
* Note: url has been decoded by OpenSearchDashboards.
*
* @param url url string.
* @returns error message if nextUrl is invalid, otherwise void.
*/
export const validateNextUrl = (url: string | undefined): string | void => {
export function validateNextUrl(
url: string | undefined,
basePath: string | undefined
): string | void {
if (url) {
const path = url.split('?')[0];
const path = url.split(/\?|#/)[0];
const bp = basePath || '';
if (!path.startsWith(bp)) {
return INVALID_NEXT_URL_PARAMETER_MESSAGE;
}
const pathMinusBase = path.replace(bp, '');
if (
!path.startsWith('/') ||
path.startsWith('//') ||
path.includes('\\') ||
path.includes('@')
!pathMinusBase.startsWith('/') ||
(pathMinusBase.length >= 2 && !/^\/[a-zA-Z_][\/a-zA-Z0-9-_]+$/.test(pathMinusBase))
) {
return INVALID_NEXT_URL_PARAMETER_MESSAGE;
}
}
};
}

0 comments on commit 9513c5e

Please sign in to comment.