-
Notifications
You must be signed in to change notification settings - Fork 327
feat(theming) add custom component style rendering #5812
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
Merged
Merged
Changes from 2 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
--- | ||
"@aws-amplify/ui-react": minor | ||
"@aws-amplify/ui": minor | ||
--- | ||
|
||
feat(theming) add custom component style rendering | ||
|
||
```jsx | ||
const customComponentTheme = defineComponentTheme({ | ||
name: 'custom-component', | ||
theme(tokens) { | ||
return { | ||
color: tokens.colors.red[10] | ||
} | ||
} | ||
}); | ||
|
||
export function CustomComponent() { | ||
return ( | ||
<> | ||
<View className={customComponentTheme.className()}> | ||
</View> | ||
// This will create a style tag with only the styles in the component theme | ||
// the styles are scoped to the global theme | ||
<ComponentStyle theme={theme} componentThemes=[customComponentTheme] /> | ||
</> | ||
) | ||
} | ||
``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
53 changes: 53 additions & 0 deletions
53
packages/react/src/components/ThemeProvider/ComponentStyle.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import * as React from 'react'; | ||
import { WebTheme, createComponentCSS } from '@aws-amplify/ui'; | ||
import { | ||
BaseComponentProps, | ||
ElementType, | ||
ForwardRefPrimitive, | ||
Primitive, | ||
PrimitiveProps, | ||
} from '../../primitives/types'; | ||
import { primitiveWithForwardRef } from '../../primitives/utils/primitiveWithForwardRef'; | ||
import { BaseComponentTheme } from '@aws-amplify/ui'; | ||
import { Style } from './Style'; | ||
|
||
interface BaseComponentStyleProps extends BaseComponentProps { | ||
/** | ||
* Provide a server generated nonce which matches your CSP `style-src` rule. | ||
* This will be attached to the generated <style> tag. | ||
* @see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src | ||
*/ | ||
nonce?: string; | ||
theme: Pick<WebTheme, 'name' | 'breakpoints' | 'tokens'>; | ||
componentThemes: BaseComponentTheme[]; | ||
} | ||
|
||
export type ComponentStyleProps<Element extends ElementType = 'style'> = | ||
PrimitiveProps<BaseComponentStyleProps, Element>; | ||
|
||
const ComponentStylePrimitive: Primitive<ComponentStyleProps, 'style'> = ( | ||
{ theme, componentThemes = [], nonce, ...rest }, | ||
ref | ||
) => { | ||
if (!theme || !componentThemes.length) { | ||
return null; | ||
} | ||
|
||
const cssText = createComponentCSS({ | ||
theme, | ||
components: componentThemes, | ||
}); | ||
|
||
return <Style {...rest} ref={ref} cssText={cssText} nonce={nonce} />; | ||
}; | ||
|
||
/** | ||
* @experimental | ||
* [📖 Docs](https://ui.docs.amplify.aws/react/components/theme) | ||
*/ | ||
export const ComponentStyle: ForwardRefPrimitive< | ||
BaseComponentStyleProps, | ||
'style' | ||
> = primitiveWithForwardRef(ComponentStylePrimitive); | ||
|
||
ComponentStyle.displayName = 'ComponentStyle'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import * as React from 'react'; | ||
import { | ||
BaseComponentProps, | ||
ElementType, | ||
ForwardRefPrimitive, | ||
Primitive, | ||
PrimitiveProps, | ||
} from '../../primitives/types'; | ||
import { primitiveWithForwardRef } from '../../primitives/utils/primitiveWithForwardRef'; | ||
|
||
interface BaseStyleProps extends BaseComponentProps { | ||
cssText?: string; | ||
} | ||
|
||
export type StyleProps<Element extends ElementType = 'style'> = PrimitiveProps< | ||
BaseStyleProps, | ||
Element | ||
>; | ||
|
||
const StylePrimitive: Primitive<StyleProps, 'style'> = ( | ||
{ cssText, ...rest }, | ||
ref | ||
) => { | ||
/* | ||
Only inject theme CSS variables if given a theme. | ||
The CSS file users import already has the default theme variables in it. | ||
This will allow users to use the provider and theme with CSS variables | ||
without having to worry about specificity issues because this stylesheet | ||
will likely come after a user's defined CSS. | ||
|
||
Q: Why are we using dangerouslySetInnerHTML? | ||
A: We need to directly inject the theme's CSS string into the <style> tag without typical HTML escaping. | ||
For example, JSX would escape characters meaningful in CSS such as ', ", < and >, thus breaking the CSS. | ||
Q: Why not use a sanitization library such as DOMPurify? | ||
A: For our use case, we specifically want to purify CSS text, *not* HTML. | ||
DOMPurify, as well as any other HTML sanitization library, would escape/encode meaningful CSS characters | ||
and break our CSS in the same way that JSX would. | ||
|
||
Q: Are there any security risks in this particular use case? | ||
A: Anything set inside of a <style> tag is always interpreted as CSS text, *not* HTML. | ||
Reference: “Restrictions on the content of raw text elements” https://html.spec.whatwg.org/dev/syntax.html#cdata-rcdata-restrictions | ||
And in our case, we are using dangerouslySetInnerHTML to set CSS text inside of a <style> tag. | ||
|
||
Thus, it really comes down to the question: Could a malicious user escape from the context of the <style> tag? | ||
For example, when inserting HTML into the DOM, could someone prematurely close the </style> tag and add a <script> tag? | ||
e.g., </style><script>alert('hello')</script> | ||
The answer depends on whether the code is rendered on the client or server side. | ||
|
||
Client side | ||
- To prevent XSS inside of the <style> tag, we need to make sure it's not closed prematurely. | ||
- This is prevented by React because React creates a style DOM node (e.g., React.createElement(‘style’, ...)), and directly sets innerHTML as a string. | ||
- Even if the string contains a closing </style> tag, it will still be interpreted as CSS text by the browser. | ||
- Therefore, there is not an XSS vulnerability on the client side. | ||
|
||
Server side | ||
- When React code is rendered on the server side (e.g., NextJS), the code is sent to the browser as HTML text. | ||
- Therefore, it *IS* possible to insert a closing </style> tag and escape the CSS context, which opens an XSS vulnerability. | ||
|
||
Q: How are we mitigating the potential attack vector? | ||
A: To fix this potential attack vector on the server side, we need to filter out any closing </style> tags, | ||
as this the only way to escape from the context of the browser interpreting the text as CSS. | ||
We also need to catch cases where there is any kind of whitespace character </style[HERE]>, such as tabs, carriage returns, etc: | ||
</style | ||
|
||
> | ||
Therefore, by only rendering CSS text which does not include a closing '</style>' tag, | ||
we ensure that the browser will correctly interpret all the text as CSS. | ||
*/ | ||
if (cssText === undefined) { | ||
return null; | ||
} | ||
if (/<\/style/i.test(cssText)) { | ||
return null; | ||
} else { | ||
return ( | ||
<style | ||
{...rest} | ||
ref={ref} | ||
// eslint-disable-next-line react/no-danger | ||
dangerouslySetInnerHTML={{ __html: cssText }} | ||
/> | ||
); | ||
} | ||
dbanksdesign marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
|
||
/** | ||
* @experimental | ||
* [📖 Docs](https://ui.docs.amplify.aws/react/components/theme) | ||
*/ | ||
export const Style: ForwardRefPrimitive<BaseStyleProps, 'style'> = | ||
primitiveWithForwardRef(StylePrimitive); | ||
|
||
Style.displayName = 'Style'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
43 changes: 43 additions & 0 deletions
43
packages/react/src/components/ThemeProvider/__tests__/ComponentStyle.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { render } from '@testing-library/react'; | ||
import * as React from 'react'; | ||
|
||
import { ComponentStyle } from '../ComponentStyle'; | ||
import { createTheme, defineComponentTheme } from '@aws-amplify/ui'; | ||
|
||
describe('ComponentStyle', () => { | ||
it('does not render anything if no theme is passed', async () => { | ||
// @ts-expect-error - missing props | ||
const { container } = render(<ComponentStyle />); | ||
|
||
const styleTag = container.querySelector(`style`); | ||
expect(styleTag).toBe(null); | ||
}); | ||
|
||
it('does not render anything if no component themes are passed', async () => { | ||
// @ts-expect-error - missing props | ||
const { container } = render(<ComponentStyle theme={createTheme()} />); | ||
|
||
const styleTag = container.querySelector(`style`); | ||
expect(styleTag).toBe(null); | ||
}); | ||
|
||
it('renders a style tag if theme and component themes are passed', async () => { | ||
const testComponentTheme = defineComponentTheme({ | ||
name: 'test', | ||
theme(tokens) { | ||
return { | ||
color: tokens.colors.red[100], | ||
}; | ||
}, | ||
}); | ||
const { container } = render( | ||
<ComponentStyle | ||
theme={createTheme()} | ||
componentThemes={[testComponentTheme]} | ||
/> | ||
); | ||
|
||
const styleTag = container.querySelector(`style`); | ||
expect(styleTag).toBeInTheDocument(); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
packages/ui/src/theme/createTheme/__tests__/__snapshots__/defineComponentTheme.test.ts.snap
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
// Jest Snapshot v1, https://goo.gl/fbAQLP | ||
|
||
exports[`@aws-amplify/ui defineComponentTheme should return a cssText function 1`] = ` | ||
"[data-amplify-theme="default-theme"] .amplify-test { background-color:pink; border-radius:var(--amplify-radii-small); } | ||
[data-amplify-theme="default-theme"] .amplify-test--small { border-radius:0; } | ||
" | ||
`; | ||
|
||
exports[`@aws-amplify/ui defineComponentTheme should return a cssText function that works with custom tokens 1`] = ` | ||
"[data-amplify-theme="test"] .amplify-test { background-color:var(--amplify-colors-hot-pink-10); } | ||
" | ||
`; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.