diff --git a/resources/js/oauth2/consent.js b/resources/js/oauth2/consent.js index 898141a4..3bf7e993 100644 --- a/resources/js/oauth2/consent.js +++ b/resources/js/oauth2/consent.js @@ -14,6 +14,7 @@ import Typography from "@material-ui/core/Typography"; import Tooltip from '@material-ui/core/Tooltip'; import {ClickAwayListener} from "@material-ui/core"; +import {AppName, RedirectNotice} from "./consent_components"; import styles from "./consent.module.scss"; const HtmlTooltip = withStyles((theme) => ({ @@ -36,7 +37,8 @@ const ConsentPage = ( disclaimer, formAction, redirectURL, - requestedScopes + requestedScopes, + website }) => { const formEl = useRef(null); const trustEl = useRef(null); @@ -74,7 +76,7 @@ const ConsentPage = ( idpLogo

- {appName}  +   )} } -
Clicking 'Accept' will redirect you to: {redirectURL}. -
+ } > diff --git a/resources/js/oauth2/consent_components.js b/resources/js/oauth2/consent_components.js new file mode 100644 index 00000000..f6914cc7 --- /dev/null +++ b/resources/js/oauth2/consent_components.js @@ -0,0 +1,24 @@ +import React from "react"; + +// only plain web URLs are safe to expose as a navigable link (blocks javascript:, custom schemes, etc.) +export const safeWebsiteUrl = (website) => { + if (typeof website !== 'string' || website.trim() === '') return null; + try { + const url = new URL(website.trim()); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null; + } catch (e) { + return null; + } +}; + +// NOTE: never link to the client's redirect_uri; for native clients (custom URI scheme) +// navigating to it without an authorization code kills the login. +export const AppName = ({appName, website}) => { + const href = safeWebsiteUrl(website); + if (!href) return <>{appName}; + return {appName}; +}; + +export const RedirectNotice = ({redirectURL}) => ( +
Clicking 'Accept' will redirect you to: {redirectURL}.
+); diff --git a/resources/js/oauth2/consent_components.test.js b/resources/js/oauth2/consent_components.test.js new file mode 100644 index 00000000..04201b67 --- /dev/null +++ b/resources/js/oauth2/consent_components.test.js @@ -0,0 +1,51 @@ +import React from "react"; +import {renderToStaticMarkup} from "react-dom/server"; +import {AppName, RedirectNotice, safeWebsiteUrl} from "./consent_components"; + +const NATIVE_REDIRECT = 'com.fntech.ftnattendee://callback'; + +describe('safeWebsiteUrl', () => { + it('accepts http and https', () => { + expect(safeWebsiteUrl('https://example.com/app')).toBe('https://example.com/app'); + expect(safeWebsiteUrl('http://example.com/')).toBe('http://example.com/'); + }); + + it('rejects empty, non-string, malformed and non-web schemes', () => { + expect(safeWebsiteUrl(null)).toBeNull(); + expect(safeWebsiteUrl(undefined)).toBeNull(); + expect(safeWebsiteUrl(' ')).toBeNull(); + expect(safeWebsiteUrl('not a url')).toBeNull(); + expect(safeWebsiteUrl('javascript:alert(1)')).toBeNull(); + expect(safeWebsiteUrl(NATIVE_REDIRECT)).toBeNull(); + }); +}); + +describe('AppName', () => { + it('links to the website in a new tab when configured', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('href="https://example.com/"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + expect(html).toContain('My App'); + }); + + it('is plain text without a website', () => { + const html = renderToStaticMarkup(); + expect(html).toBe('My App'); + expect(html).not.toContain(' { + const html = renderToStaticMarkup(); + expect(html).not.toContain(' { + it('shows a native redirect_uri as text, never as a link', () => { + const html = renderToStaticMarkup(); + expect(html).toContain(NATIVE_REDIRECT); + expect(html).not.toContain('