Bug 1513311 - Allow remote custom CSS to adjust section and/or card styles (#4674)

This commit is contained in:
Ed Lee 2019-01-17 11:23:05 -08:00 коммит произвёл GitHub
Родитель 62c87514b3
Коммит e34ed22aef
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
2 изменённых файлов: 122 добавлений и 4 удалений

Просмотреть файл

@ -19,7 +19,32 @@ const MAX_ROWS_HERO = 5;
const MAX_ROWS_LISTS = 5;
const MAX_ROWS_CARDGRID = 8;
const ALLOWED_CSS_URL_PREFIXES = ["chrome://", "resource://", "https://img-getpocket.cdn.mozilla.net/"];
const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR";
/**
* Validate a CSS declaration. The values are assumed to be normalized by CSSOM.
*/
export function isAllowedCSS(property, value) {
// Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are
// exposed but their values aren't resulting in getting nothing. Fortunately,
// we don't care about validating the values of the current set of properties.
if (value === undefined) {
return true;
}
// Make sure all urls are of the allowed protocols/prefixes
const urls = value.match(/url\("[^"]+"\)/g);
return !urls || urls.every(url => ALLOWED_CSS_URL_PREFIXES.some(prefix =>
url.slice(5).startsWith(prefix)));
}
export class _DiscoveryStreamBase extends React.PureComponent {
constructor(props) {
super(props);
this.onStyleMount = this.onStyleMount.bind(this);
}
extractRows(component, limit) {
if (component.data && component.data.recommendations) {
const items = Math.min(limit, component.properties.items || component.data.recommendations.length);
@ -29,6 +54,54 @@ export class _DiscoveryStreamBase extends React.PureComponent {
return [];
}
onStyleMount(style) {
// Unmounting style gets rid of old styles, so nothing else to do
if (!style) {
return;
}
const {sheet} = style;
const styles = JSON.parse(style.dataset.styles);
styles.forEach((row, rowIndex) => {
row.forEach((component, componentIndex) => {
// Nothing to do without optional styles overrides
if (!component) {
return;
}
Object.entries(component).forEach(([selectors, declarations]) => {
// Start with a dummy rule to validate declarations and selectors
sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`);
const [rule] = sheet.cssRules;
// Validate declarations and remove any offenders. CSSOM silently
// discards invalid entries, so here we apply extra restrictions.
rule.style = declarations;
[...rule.style].forEach(property => {
const value = rule.style[property];
if (!isAllowedCSS(property, value)) {
console.error(`Bad CSS declaration ${property}: ${value}`); // eslint-disable-line no-console
rule.style.removeProperty(property);
}
});
// Set the actual desired selectors scoped to the component
const prefix = `.ds-layout > .ds-column:nth-child(${rowIndex + 1}) > :nth-child(${componentIndex + 1})`;
// NB: Splitting on "," doesn't work with strings with commas, but
// we're okay with not supporting those selectors
rule.selectorText = selectors.split(",").map(selector => prefix +
// Assume :pseudo-classes are for component instead of descendant
(selector[0] === ":" ? "" : " ") + selector).join(",");
// CSSOM silently ignores bad selectors, so we'll be noisy instead
if (rule.selectorText === DUMMY_CSS_SELECTOR) {
console.error(`Bad CSS selector ${selectors}`); // eslint-disable-line no-console
}
});
});
});
}
renderComponent(component) {
let rows;
@ -81,19 +154,29 @@ export class _DiscoveryStreamBase extends React.PureComponent {
}
}
renderStyles(styles) {
// Use json string as both the key and styles to render so React knows when
// to unmount and mount a new instance for new styles.
const json = JSON.stringify(styles);
return (<style key={json} data-styles={json} ref={this.onStyleMount} />);
}
render() {
const {layoutRender} = this.props.DiscoveryStream;
const styles = [];
return (
<div className="discovery-stream ds-layout">
{layoutRender.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} className={`ds-column ds-column-${row.width}`}>
{row.components.map((component, componentIndex) => (
<div key={`component-${componentIndex}`}>
{row.components.map((component, componentIndex) => {
styles[rowIndex] = [...styles[rowIndex] || [], component.styles];
return (<div key={`component-${componentIndex}`}>
{this.renderComponent(component)}
</div>
))}
</div>);
})}
</div>
))}
{this.renderStyles(styles)}
</div>
);
}

Просмотреть файл

@ -0,0 +1,35 @@
import {isAllowedCSS} from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase";
describe("<isAllowedCSS>", () => {
it("should allow colors", () => {
assert.isTrue(isAllowedCSS("color", "red"));
});
it("should allow resource urls", () => {
assert.isTrue(isAllowedCSS("background-image", `url("resource://activity-stream/data/content/assets/glyph-info-16.svg")`));
});
it("should allow chrome urls", () => {
assert.isTrue(isAllowedCSS("background-image", `url("chrome://browser/skin/history.svg")`));
});
it("should allow allowed https urls", () => {
assert.isTrue(isAllowedCSS("background-image", `url("https://img-getpocket.cdn.mozilla.net/media/image.png")`));
});
it("should disallow other https urls", () => {
assert.isFalse(isAllowedCSS("background-image", `url("https://mozilla.org/media/image.png")`));
});
it("should disallow other protocols", () => {
assert.isFalse(isAllowedCSS("background-image", `url("ftp://mozilla.org/media/image.png")`));
});
it("should allow allowed multiple valid urls", () => {
assert.isTrue(isAllowedCSS("background-image", `url("https://img-getpocket.cdn.mozilla.net/media/image.png"), url("chrome://browser/skin/history.svg")`));
});
it("should disallow if any invaild", () => {
assert.isFalse(isAllowedCSS("background-image", `url("chrome://browser/skin/history.svg"), url("ftp://mozilla.org/media/image.png")`));
});
});