Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use spec-compliant URL parser to parse next URL parameter. #183521

Merged
merged 4 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,10 @@ x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kib
/src/dev/eslint/security_eslint_rule_tests.ts @elastic/kibana-security
/src/core/server/integration_tests/config/check_dynamic_config.test.ts @elastic/kibana-security
/src/plugins/telemetry/server/config/telemetry_labels.ts @elastic/kibana-security
/packages/kbn-std/src/is_internal_url.test.ts @elastic/kibana-core @elastic/kibana-security
/packages/kbn-std/src/is_internal_url.ts @elastic/kibana-core @elastic/kibana-security
/packages/kbn-std/src/parse_next_url.test.ts @elastic/kibana-core @elastic/kibana-security
/packages/kbn-std/src/parse_next_url.ts @elastic/kibana-core @elastic/kibana-security
/test/interactive_setup_api_integration/ @elastic/kibana-security
/test/interactive_setup_functional/ @elastic/kibana-security
/test/plugin_functional/test_suites/core_plugins/rendering.ts @elastic/kibana-security
Expand Down
46 changes: 46 additions & 0 deletions packages/kbn-std/src/is_internal_url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ describe('isInternalURL', () => {
const href = `${basePath}/app/kibana/../../../management`;
expect(isInternalURL(href, basePath)).toBe(false);
});

it('should return `false` if absolute URL contains tabs or new lines', () => {
for (const char of ['\t', '\n', '\r', '\t\n\r']) {
for (const url of [
`/${char}/${basePath}app/kibana`,
`/${char}//${basePath}app/kibana`,
`//${char}/${basePath}app/kibana`,
`htt${char}ps://example.com${basePath}`,
`htt${char}p://example.org:5601${basePath}`,
`file${char}://example.com${basePath}`,
`htt${char}p://example.org:5601${char}${basePath}`,
`https:/${char}/example.com${char}${basePath}`,
]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't these all return false anyway, even without the special character? Should we have at least one test case that would otherwise return true, if not for the tab/newline?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've explained the idea in https://github.com/elastic/kibana/pull/183521/files#r1603774927, but in this specific case (with the base path) you're right - these all seem to return false even with the previous code, which is not what we want. Let me take a look.

expect(isInternalURL(url, basePath)).toBe(false);
}
}
});
});

describe('without basePath defined', () => {
Expand All @@ -86,5 +103,34 @@ describe('isInternalURL', () => {
const hrefWithThreeSlashes = `///app/kibana`;
expect(isInternalURL(hrefWithThreeSlashes)).toBe(false);
});

it('should return `false` if absolute URL contains tabs or new lines', () => {
for (const char of ['\t', '\n', '\r', '\t\n\r']) {
for (const url of [
`/${char}/app/kibana`,
`/${char}//app/kibana`,
`//${char}/app/kibana`,
`/${char}/example.com`,
`/${char}//example.com`,
`//${char}/example.com`,
`htt${char}ps://example.com`,
`/${char}/example.org`,
`/${char}//example.org`,
`//${char}/example.org`,
`htt${char}ps://example.org`,
`/${char}/example.org:5601`,
`/${char}//example.org:5601`,
`//${char}/example.org:5601`,
`htt${char}ps://example.org:5601`,
`java${char}script:alert(1)`,
`htt${char}p://example.org:5601`,
`https:/${char}/example.com`,
`file${char}://example.com`,
`https://example${char}.com/path`,
]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. These would all return false, even without the special character. Should we have at least one test case that would otherwise return true, if not for the tab/newline?

I tried this with using '/app/kibana#/discover/New-Saved-Search' (from the true test case) and adding the special char in various places, but it still returned true. Am I misinterpreting the test?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These would all return false, even without the special character.

Without the special character, they will behave as expected with both the current and previous code and return false, and we have existing tests for this behavior. The issue I'm trying to cover here is that with the previous code, the presence of the special character made this function mistakenly return true instead of false. Try reverting the changes in is_internal_url.ts but keep the tests.

Does that make sense?

Should we have at least one test case that would otherwise return true, if not for the tab/newline?

Yeah, I think it's a good idea. What about having a similar loop with a subset of the URLs tested here, but with a white space instead of \t\r\n that should cause the function to return true?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I've made an attempt to make the intention a bit clearer in unit tests in 96772c3. @jeramysoucy let me know if it helps.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is much more clear to me now. Thanks, Oleg!

expect(isInternalURL(url)).toBe(false);
}
}
});
});
});
40 changes: 19 additions & 21 deletions packages/kbn-std/src/is_internal_url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,35 @@
* Side Public License, v 1.
*/

import { parse as parseUrl } from 'url';

/**
* Determine if url is outside of this Kibana install.
*/
export function isInternalURL(url: string, basePath = '') {
const { protocol, hostname, port, pathname } = parseUrl(
url,
false /* parseQueryString */,
true /* slashesDenoteHost */
);

// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
// and the first slash that belongs to path.
if (protocol !== null || hostname !== null || port !== null) {
// We use the WHATWG parser TWICE with completely different dummy base URLs to ensure that the parsed URL always
// inherits the origin of the base URL. This means that the specified URL isn't an absolute URL, or a scheme-relative
// URL (//), or a scheme-relative URL with an empty host (///). Browsers may process such URLs unexpectedly due to
// backward compatibility reasons (e.g., a browser may treat `///abc.com` as just `abc.com`). For more details, refer
// to https://url.spec.whatwg.org/#concept-basic-url-parser and https://url.spec.whatwg.org/#url-representation.
let normalizedURL: URL;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: hopefully, we can get rid of this trickery when/if this is accepted and implemented: whatwg/url#531

try {
for (const baseURL of ['http://example.org:5601', 'https://example.com']) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QQ: why do we need the one with the port there? isn't https://example.com not enough for normalization?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on what we know today, it's redundant, agreed. The idea was to have two base URLs that differ in all three parts of the origin (protocol, host, and port) just to ensure there is no way to exploit any backward compatibility browser quirk that we're not aware of yet to make the browser treat a relative URL as an absolute one.

normalizedURL = new URL(url, baseURL);
if (normalizedURL.origin !== baseURL) {
return false;
}
}
} catch {
return false;
}

// Now we need to normalize URL to make sure any relative path segments (`..`) cannot escape expected base path.
if (basePath) {
// Now we need to normalize URL to make sure any relative path segments (`..`) cannot escape expected
// base path. We can rely on `URL` with a localhost to automatically "normalize" the URL.
const normalizedPathname = new URL(String(pathname), 'https://localhost').pathname;

return (
// Normalized pathname can add a leading slash, but we should also make sure it's included in
// the original URL too
pathname?.startsWith('/') &&
(normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`))
// the original URL too. We can safely use non-null assertion operator here since we know `normalizedURL` is
// always defined, otherwise we would have returned `false` already.
url.startsWith('/') &&
(normalizedURL!.pathname === basePath || normalizedURL!.pathname.startsWith(`${basePath}/`))
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,44 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(currentURL.pathname).to.eql('/app/management/security/users');
});

it('can login with Login Form resetting target URL', async () => {
this.timeout(120000);

for (const targetURL of [
'///example.com',
'//example.com',
'https://example.com',

'/\t/example.com',
'/\n/example.com',
'/\r/example.com',

'/\t//example.com',
'/\n//example.com',
'/\r//example.com',

'//\t/example.com',
'//\n/example.com',
'//\r/example.com',

'ht\ttps://example.com',
'ht\ntps://example.com',
'ht\rtps://example.com',
]) {
await browser.get(
`${deployment.getHostPort()}/login?next=${encodeURIComponent(targetURL)}`
);

await PageObjects.common.waitUntilUrlIncludes('next=');
await PageObjects.security.loginSelector.login('basic', 'basic1');
// We need to make sure that both path and hash are respected.
const currentURL = parse(await browser.getCurrentUrl());

expect(currentURL.pathname).to.eql('/app/home');
await PageObjects.security.forceLogout();
}
});

it('can login with SSO preserving original URL', async () => {
await PageObjects.common.navigateToUrl('management', 'security/users', {
ensureCurrentUrl: false,
Expand Down
38 changes: 38 additions & 0 deletions x-pack/test/security_functional/tests/oidc/url_capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.path).to.eql('/authentication/app?one=two');
});

it('resets invalid target URL', async () => {
this.timeout(120000);

for (const targetURL of [
'///example.com',
'//example.com',
'https://example.com',

'/\t/example.com',
'/\n/example.com',
'/\r/example.com',

'/\t//example.com',
'/\n//example.com',
'/\r//example.com',

'//\t/example.com',
'//\n/example.com',
'//\r/example.com',

'ht\ttps://example.com',
'ht\ntps://example.com',
'ht\rtps://example.com',
]) {
await browser.get(
`${deployment.getHostPort()}/internal/security/capture-url?next=${encodeURIComponent(
targetURL
)}`
);

await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
expect(parse(await browser.getCurrentUrl()).pathname).to.eql('/app/home');

await browser.get(deployment.getHostPort() + '/logout');
await PageObjects.common.waitUntilUrlIncludes('logged_out');
}
});
});
}
38 changes: 38 additions & 0 deletions x-pack/test/security_functional/tests/saml/url_capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.path).to.eql('/authentication/app?one=two');
});

it('resets invalid target URL', async () => {
this.timeout(120000);

for (const targetURL of [
'///example.com',
'//example.com',
'https://example.com',

'/\t/example.com',
'/\n/example.com',
'/\r/example.com',

'/\t//example.com',
'/\n//example.com',
'/\r//example.com',

'//\t/example.com',
'//\n/example.com',
'//\r/example.com',

'ht\ttps://example.com',
'ht\ntps://example.com',
'ht\rtps://example.com',
]) {
await browser.get(
`${deployment.getHostPort()}/internal/security/capture-url?next=${encodeURIComponent(
targetURL
)}`
);

await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
expect(parse(await browser.getCurrentUrl()).pathname).to.eql('/app/home');

await browser.get(deployment.getHostPort() + '/logout');
await PageObjects.common.waitUntilUrlIncludes('logged_out');
}
});
});
}