This commit changes markdown sanitization behaviour in following way: class, style and data-* attributes are removed by default. These attributes open possible vulnerability vectors to attackers. The original behavior of sanitizer (before this commit) can be enabled by *useUnsafeMarkdown* configuration option. Use this configuration option with caution and only in cases when you know what you're doing.bubble
@@ -71,6 +71,10 @@ const standardVariables = { | |||||
type: "boolean", | type: "boolean", | ||||
name: "showCommonExtensions" | name: "showCommonExtensions" | ||||
}, | }, | ||||
USE_UNSAFE_MARKDOWN: { | |||||
type: "boolean", | |||||
name: "useUnsafeMarkdown" | |||||
}, | |||||
OAUTH2_REDIRECT_URL: { | OAUTH2_REDIRECT_URL: { | ||||
type: "string", | type: "string", | ||||
name: "oauth2RedirectUrl" | name: "oauth2RedirectUrl" | ||||
@@ -59,6 +59,7 @@ Parameter name | Docker variable | Description | |||||
<a name="showExtensions"></a>`showExtensions` | `SHOW_EXTENSIONS` | `Boolean=false`. Controls the display of vendor extension (`x-`) fields and values for Operations, Parameters, and Schema. | <a name="showExtensions"></a>`showExtensions` | `SHOW_EXTENSIONS` | `Boolean=false`. Controls the display of vendor extension (`x-`) fields and values for Operations, Parameters, and Schema. | ||||
<a name="showCommonExtensions"></a>`showCommonExtensions` | `SHOW_COMMON_EXTENSIONS` | `Boolean=false`. Controls the display of extensions (`pattern`, `maxLength`, `minLength`, `maximum`, `minimum`) fields and values for Parameters. | <a name="showCommonExtensions"></a>`showCommonExtensions` | `SHOW_COMMON_EXTENSIONS` | `Boolean=false`. Controls the display of extensions (`pattern`, `maxLength`, `minLength`, `maximum`, `minimum`) fields and values for Parameters. | ||||
<a name="tagSorter"></a>`tagsSorter` | _Unavailable_ | `Function=(a => a)`. Apply a sort to the tag list of each API. It can be 'alpha' (sort by paths alphanumerically) or a function (see [Array.prototype.sort()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) to learn how to write a sort function). Two tag name strings are passed to the sorter for each pass. Default is the order determined by Swagger UI. | <a name="tagSorter"></a>`tagsSorter` | _Unavailable_ | `Function=(a => a)`. Apply a sort to the tag list of each API. It can be 'alpha' (sort by paths alphanumerically) or a function (see [Array.prototype.sort()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) to learn how to write a sort function). Two tag name strings are passed to the sorter for each pass. Default is the order determined by Swagger UI. | ||||
<a name="useUnsafeMarkdown"></a>`useUnsafeMarkdown` | `USE_UNSAFE_MARKDOWN` | `Boolean=false`. When enabled, sanitizer will leave `style`, `class` and `data-*` attributes untouched on all HTML Elements declared inside markdown strings. This parameter is **Deprecated** and will be removed in `4.0.0`. | |||||
<a name="onComplete"></a>`onComplete` | _Unavailable_ | `Function=NOOP`. Provides a mechanism to be notified when Swagger UI has finished rendering a newly provided definition. | <a name="onComplete"></a>`onComplete` | _Unavailable_ | `Function=NOOP`. Provides a mechanism to be notified when Swagger UI has finished rendering a newly provided definition. | ||||
##### Network | ##### Network | ||||
@@ -25,7 +25,7 @@ export default class ArrayModel extends Component { | |||||
let title = schema.get("title") || displayName || name | let title = schema.get("title") || displayName || name | ||||
let properties = schema.filter( ( v, key) => ["type", "items", "description", "$$ref"].indexOf(key) === -1 ) | let properties = schema.filter( ( v, key) => ["type", "items", "description", "$$ref"].indexOf(key) === -1 ) | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const ModelCollapse = getComponent("ModelCollapse") | const ModelCollapse = getComponent("ModelCollapse") | ||||
const Model = getComponent("Model") | const Model = getComponent("Model") | ||||
const Property = getComponent("Property") | const Property = getComponent("Property") | ||||
@@ -44,7 +44,7 @@ export default class ApiKeyAuth extends React.Component { | |||||
const Row = getComponent("Row") | const Row = getComponent("Row") | ||||
const Col = getComponent("Col") | const Col = getComponent("Col") | ||||
const AuthError = getComponent("authError") | const AuthError = getComponent("authError") | ||||
const Markdown = getComponent( "Markdown" ) | |||||
const Markdown = getComponent("Markdown", true) | |||||
const JumpToPath = getComponent("JumpToPath", true) | const JumpToPath = getComponent("JumpToPath", true) | ||||
let value = this.getValue() | let value = this.getValue() | ||||
let errors = errSelectors.allErrors().filter( err => err.get("authId") === name) | let errors = errSelectors.allErrors().filter( err => err.get("authId") === name) | ||||
@@ -51,7 +51,7 @@ export default class BasicAuth extends React.Component { | |||||
const Col = getComponent("Col") | const Col = getComponent("Col") | ||||
const AuthError = getComponent("authError") | const AuthError = getComponent("authError") | ||||
const JumpToPath = getComponent("JumpToPath", true) | const JumpToPath = getComponent("JumpToPath", true) | ||||
const Markdown = getComponent( "Markdown" ) | |||||
const Markdown = getComponent("Markdown", true) | |||||
let username = this.getValue().username | let username = this.getValue().username | ||||
let errors = errSelectors.allErrors().filter( err => err.get("authId") === name) | let errors = errSelectors.allErrors().filter( err => err.get("authId") === name) | ||||
@@ -109,7 +109,7 @@ export default class Oauth2 extends React.Component { | |||||
const Button = getComponent("Button") | const Button = getComponent("Button") | ||||
const AuthError = getComponent("authError") | const AuthError = getComponent("authError") | ||||
const JumpToPath = getComponent("JumpToPath", true) | const JumpToPath = getComponent("JumpToPath", true) | ||||
const Markdown = getComponent( "Markdown" ) | |||||
const Markdown = getComponent("Markdown", true) | |||||
const InitializedInput = getComponent("InitializedInput") | const InitializedInput = getComponent("InitializedInput") | ||||
const { isOAS3 } = specSelectors | const { isOAS3 } = specSelectors | ||||
@@ -10,7 +10,7 @@ import { stringify } from "core/utils" | |||||
export default function Example(props) { | export default function Example(props) { | ||||
const { example, showValue, getComponent } = props | const { example, showValue, getComponent } = props | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const HighlightCode = getComponent("highlightCode") | const HighlightCode = getComponent("highlightCode") | ||||
if(!example) return null | if(!example) return null | ||||
@@ -14,7 +14,7 @@ export default class Headers extends React.Component { | |||||
let { headers, getComponent } = this.props | let { headers, getComponent } = this.props | ||||
const Property = getComponent("Property") | const Property = getComponent("Property") | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
if ( !headers || !headers.size ) | if ( !headers || !headers.size ) | ||||
return null | return null | ||||
@@ -36,7 +36,7 @@ export default class Headers extends React.Component { | |||||
if(!Im.Map.isMap(header)) { | if(!Im.Map.isMap(header)) { | ||||
return null | return null | ||||
} | } | ||||
const description = header.get("description") | const description = header.get("description") | ||||
const type = header.getIn(["schema"]) ? header.getIn(["schema", "type"]) : header.getIn(["type"]) | const type = header.getIn(["schema"]) ? header.getIn(["schema", "type"]) : header.getIn(["type"]) | ||||
const schemaExample = header.getIn(["schema", "example"]) | const schemaExample = header.getIn(["schema", "example"]) | ||||
@@ -61,7 +61,7 @@ class License extends React.Component { | |||||
let { license, getComponent } = this.props | let { license, getComponent } = this.props | ||||
const Link = getComponent("Link") | const Link = getComponent("Link") | ||||
let name = license.get("name") || "License" | let name = license.get("name") || "License" | ||||
let url = license.get("url") | let url = license.get("url") | ||||
@@ -82,7 +82,7 @@ export class InfoUrl extends React.PureComponent { | |||||
getComponent: PropTypes.func.isRequired | getComponent: PropTypes.func.isRequired | ||||
} | } | ||||
render() { | render() { | ||||
const { url, getComponent } = this.props | const { url, getComponent } = this.props | ||||
@@ -112,7 +112,7 @@ export default class Info extends React.Component { | |||||
let license = info.get("license") | let license = info.get("license") | ||||
const { url:externalDocsUrl, description:externalDocsDescription } = (externalDocs || fromJS({})).toJS() | const { url:externalDocsUrl, description:externalDocsDescription } = (externalDocs || fromJS({})).toJS() | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const Link = getComponent("Link") | const Link = getComponent("Link") | ||||
const VersionStamp = getComponent("VersionStamp") | const VersionStamp = getComponent("VersionStamp") | ||||
const InfoUrl = getComponent("InfoUrl") | const InfoUrl = getComponent("InfoUrl") | ||||
@@ -40,7 +40,7 @@ export default class ObjectModel extends Component { | |||||
let requiredProperties = schema.get("required") | let requiredProperties = schema.get("required") | ||||
const JumpToPath = getComponent("JumpToPath", true) | const JumpToPath = getComponent("JumpToPath", true) | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const Model = getComponent("Model") | const Model = getComponent("Model") | ||||
const ModelCollapse = getComponent("ModelCollapse") | const ModelCollapse = getComponent("ModelCollapse") | ||||
@@ -44,7 +44,7 @@ export default class OperationTag extends React.Component { | |||||
const isDeepLinkingEnabled = deepLinking && deepLinking !== "false" | const isDeepLinkingEnabled = deepLinking && deepLinking !== "false" | ||||
const Collapse = getComponent("Collapse") | const Collapse = getComponent("Collapse") | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const DeepLink = getComponent("DeepLink") | const DeepLink = getComponent("DeepLink") | ||||
const Link = getComponent("Link") | const Link = getComponent("Link") | ||||
@@ -93,7 +93,7 @@ export default class Operation extends PureComponent { | |||||
const Execute = getComponent( "execute" ) | const Execute = getComponent( "execute" ) | ||||
const Clear = getComponent( "clear" ) | const Clear = getComponent( "clear" ) | ||||
const Collapse = getComponent( "Collapse" ) | const Collapse = getComponent( "Collapse" ) | ||||
const Markdown = getComponent( "Markdown" ) | |||||
const Markdown = getComponent("Markdown", true) | |||||
const Schemes = getComponent( "schemes" ) | const Schemes = getComponent( "schemes" ) | ||||
const OperationServers = getComponent( "OperationServers" ) | const OperationServers = getComponent( "OperationServers" ) | ||||
const OperationExt = getComponent( "OperationExt" ) | const OperationExt = getComponent( "OperationExt" ) | ||||
@@ -102,7 +102,7 @@ export default class ParameterRow extends Component { | |||||
.get("content", Map()) | .get("content", Map()) | ||||
.keySeq() | .keySeq() | ||||
.first() | .first() | ||||
// getSampleSchema could return null | // getSampleSchema could return null | ||||
const generatedSampleValue = schema ? getSampleSchema(schema.toJS(), parameterMediaType, { | const generatedSampleValue = schema ? getSampleSchema(schema.toJS(), parameterMediaType, { | ||||
includeWriteOnly: true | includeWriteOnly: true | ||||
@@ -144,7 +144,7 @@ export default class ParameterRow extends Component { | |||||
this.onChangeWrapper(initialValue) | this.onChangeWrapper(initialValue) | ||||
} else if( | } else if( | ||||
schema && schema.get("type") === "object" | schema && schema.get("type") === "object" | ||||
&& generatedSampleValue | |||||
&& generatedSampleValue | |||||
&& !paramWithMeta.get("examples") | && !paramWithMeta.get("examples") | ||||
) { | ) { | ||||
// Object parameters get special treatment.. if the user doesn't set any | // Object parameters get special treatment.. if the user doesn't set any | ||||
@@ -202,7 +202,7 @@ export default class ParameterRow extends Component { | |||||
/> | /> | ||||
const ModelExample = getComponent("modelExample") | const ModelExample = getComponent("modelExample") | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const ParameterExt = getComponent("ParameterExt") | const ParameterExt = getComponent("ParameterExt") | ||||
const ParameterIncludeEmpty = getComponent("ParameterIncludeEmpty") | const ParameterIncludeEmpty = getComponent("ParameterIncludeEmpty") | ||||
const ExamplesSelectValueRetainer = getComponent("ExamplesSelectValueRetainer") | const ExamplesSelectValueRetainer = getComponent("ExamplesSelectValueRetainer") | ||||
@@ -34,7 +34,7 @@ export default class Primitive extends Component { | |||||
let properties = schema | let properties = schema | ||||
.filter( ( v, key) => ["enum", "type", "format", "description", "$$ref"].indexOf(key) === -1 ) | .filter( ( v, key) => ["enum", "type", "format", "description", "$$ref"].indexOf(key) === -1 ) | ||||
.filterNot( (v, key) => extensions.has(key) ) | .filterNot( (v, key) => extensions.has(key) ) | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const EnumModel = getComponent("EnumModel") | const EnumModel = getComponent("EnumModel") | ||||
const Property = getComponent("Property") | const Property = getComponent("Property") | ||||
@@ -16,7 +16,7 @@ DomPurify.addHook("beforeSanitizeElements", function (current, ) { | |||||
return current | return current | ||||
}) | }) | ||||
function Markdown({ source, className = "" }) { | |||||
function Markdown({ source, className = "", getConfigs }) { | |||||
if (typeof source !== "string") { | if (typeof source !== "string") { | ||||
return null | return null | ||||
} | } | ||||
@@ -30,8 +30,9 @@ function Markdown({ source, className = "" }) { | |||||
md.core.ruler.disable(["replacements", "smartquotes"]) | md.core.ruler.disable(["replacements", "smartquotes"]) | ||||
const { useUnsafeMarkdown } = getConfigs() | |||||
const html = md.render(source) | const html = md.render(source) | ||||
const sanitized = sanitizer(html) | |||||
const sanitized = sanitizer(html, { useUnsafeMarkdown }) | |||||
if (!source || !html || !sanitized) { | if (!source || !html || !sanitized) { | ||||
return null | return null | ||||
@@ -44,14 +45,30 @@ function Markdown({ source, className = "" }) { | |||||
Markdown.propTypes = { | Markdown.propTypes = { | ||||
source: PropTypes.string.isRequired, | source: PropTypes.string.isRequired, | ||||
className: PropTypes.string | |||||
className: PropTypes.string, | |||||
getConfigs: PropTypes.func, | |||||
} | |||||
Markdown.defaultProps = { | |||||
getConfigs: () => ({ useUnsafeMarkdown: false }), | |||||
} | } | ||||
export default Markdown | export default Markdown | ||||
export function sanitizer(str) { | |||||
export function sanitizer(str, { useUnsafeMarkdown = false } = {}) { | |||||
const ALLOW_DATA_ATTR = useUnsafeMarkdown | |||||
const FORBID_ATTR = useUnsafeMarkdown ? [] : ["style", "class"] | |||||
if (useUnsafeMarkdown && !sanitizer.hasWarnedAboutDeprecation) { | |||||
console.warn(`useUnsafeMarkdown display configuration parameter is deprecated since >3.26.0 and will be removed in v4.0.0.`) | |||||
sanitizer.hasWarnedAboutDeprecation = true | |||||
} | |||||
return DomPurify.sanitize(str, { | return DomPurify.sanitize(str, { | ||||
ADD_ATTR: ["target"], | ADD_ATTR: ["target"], | ||||
FORBID_TAGS: ["style"], | FORBID_TAGS: ["style"], | ||||
ALLOW_DATA_ATTR, | |||||
FORBID_ATTR, | |||||
}) | }) | ||||
} | } | ||||
sanitizer.hasWarnedAboutDeprecation = false |
@@ -93,7 +93,7 @@ export default class Response extends React.Component { | |||||
const Headers = getComponent("headers") | const Headers = getComponent("headers") | ||||
const HighlightCode = getComponent("highlightCode") | const HighlightCode = getComponent("highlightCode") | ||||
const ModelExample = getComponent("modelExample") | const ModelExample = getComponent("modelExample") | ||||
const Markdown = getComponent( "Markdown" ) | |||||
const Markdown = getComponent("Markdown", true) | |||||
const OperationLink = getComponent("operationLink") | const OperationLink = getComponent("operationLink") | ||||
const ContentType = getComponent("contentType") | const ContentType = getComponent("contentType") | ||||
const ExamplesSelect = getComponent("ExamplesSelect") | const ExamplesSelect = getComponent("ExamplesSelect") | ||||
@@ -5,7 +5,7 @@ import { fromJS } from "immutable" | |||||
const Callbacks = (props) => { | const Callbacks = (props) => { | ||||
let { callbacks, getComponent, specPath } = props | let { callbacks, getComponent, specPath } = props | ||||
// const Markdown = getComponent("Markdown") | |||||
// const Markdown = getComponent("Markdown", true) | |||||
const OperationContainer = getComponent("OperationContainer", true) | const OperationContainer = getComponent("OperationContainer", true) | ||||
if(!callbacks) { | if(!callbacks) { | ||||
@@ -51,7 +51,7 @@ export default class HttpAuth extends React.Component { | |||||
const Row = getComponent("Row") | const Row = getComponent("Row") | ||||
const Col = getComponent("Col") | const Col = getComponent("Col") | ||||
const AuthError = getComponent("authError") | const AuthError = getComponent("authError") | ||||
const Markdown = getComponent( "Markdown" ) | |||||
const Markdown = getComponent("Markdown", true) | |||||
const JumpToPath = getComponent("JumpToPath", true) | const JumpToPath = getComponent("JumpToPath", true) | ||||
const scheme = (schema.get("scheme") || "").toLowerCase() | const scheme = (schema.get("scheme") || "").toLowerCase() | ||||
@@ -6,7 +6,7 @@ class OperationLink extends Component { | |||||
render() { | render() { | ||||
const { link, name, getComponent } = this.props | const { link, name, getComponent } = this.props | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
let targetOp = link.get("operationId") || link.get("operationRef") | let targetOp = link.get("operationId") || link.get("operationRef") | ||||
let parameters = link.get("parameters") && link.get("parameters").toJS() | let parameters = link.get("parameters") && link.get("parameters").toJS() | ||||
@@ -54,7 +54,7 @@ const RequestBody = ({ | |||||
onChange(e.target.files[0]) | onChange(e.target.files[0]) | ||||
} | } | ||||
const Markdown = getComponent("Markdown") | |||||
const Markdown = getComponent("Markdown", true) | |||||
const ModelExample = getComponent("modelExample") | const ModelExample = getComponent("modelExample") | ||||
const RequestBodyEditor = getComponent("RequestBodyEditor") | const RequestBodyEditor = getComponent("RequestBodyEditor") | ||||
const HighlightCode = getComponent("highlightCode") | const HighlightCode = getComponent("highlightCode") | ||||
@@ -9,14 +9,15 @@ const parser = new Remarkable("commonmark") | |||||
parser.block.ruler.enable(["table"]) | parser.block.ruler.enable(["table"]) | ||||
parser.set({ linkTarget: "_blank" }) | parser.set({ linkTarget: "_blank" }) | ||||
export const Markdown = ({ source, className = "" }) => { | |||||
export const Markdown = ({ source, className = "", getConfigs }) => { | |||||
if(typeof source !== "string") { | if(typeof source !== "string") { | ||||
return null | return null | ||||
} | } | ||||
if ( source ) { | if ( source ) { | ||||
const { useUnsafeMarkdown } = getConfigs() | |||||
const html = parser.render(source) | const html = parser.render(source) | ||||
const sanitized = sanitizer(html) | |||||
const sanitized = sanitizer(html, { useUnsafeMarkdown }) | |||||
let trimmed | let trimmed | ||||
@@ -38,6 +39,11 @@ export const Markdown = ({ source, className = "" }) => { | |||||
Markdown.propTypes = { | Markdown.propTypes = { | ||||
source: PropTypes.string, | source: PropTypes.string, | ||||
className: PropTypes.string, | className: PropTypes.string, | ||||
getConfigs: PropTypes.func, | |||||
} | |||||
Markdown.defaultProps = { | |||||
getConfigs: () => ({ useUnsafeMarkdown: false }), | |||||
} | } | ||||
export default OAS3ComponentWrapFactory(Markdown) | export default OAS3ComponentWrapFactory(Markdown) |
@@ -7,10 +7,18 @@ import { Markdown as OAS3Markdown } from "corePlugins/oas3/wrap-components/markd | |||||
describe("Markdown component", function() { | describe("Markdown component", function() { | ||||
describe("Swagger 2.0", function() { | describe("Swagger 2.0", function() { | ||||
it("allows span elements with class attrib", function() { | |||||
const str = `<span class="method">ONE</span>` | |||||
const el = render(<Markdown source={str} />) | |||||
expect(el.html()).toEqual(`<div class="markdown"><p><span class="method">ONE</span></p>\n</div>`) | |||||
it("allows elements with class, style and data-* attribs", function() { | |||||
const getConfigs = () => ({ useUnsafeMarkdown: true }) | |||||
const str = `<span class="method" style="border-width: 1px" data-attr="value">ONE</span>` | |||||
const el = render(<Markdown source={str} getConfigs={getConfigs} />) | |||||
expect(el.html()).toEqual(`<div class="markdown"><p><span data-attr="value" style="border-width: 1px" class="method">ONE</span></p>\n</div>`) | |||||
}) | |||||
it("strips class, style and data-* attribs from elements", function() { | |||||
const getConfigs = () => ({ useUnsafeMarkdown: false }) | |||||
const str = `<span class="method" style="border-width: 1px" data-attr="value">ONE</span>` | |||||
const el = render(<Markdown source={str} getConfigs={getConfigs} />) | |||||
expect(el.html()).toEqual(`<div class="markdown"><p><span>ONE</span></p>\n</div>`) | |||||
}) | }) | ||||
it("allows td elements with colspan attrib", function() { | it("allows td elements with colspan attrib", function() { | ||||
@@ -57,6 +65,20 @@ describe("Markdown component", function() { | |||||
}) | }) | ||||
describe("OAS 3", function() { | describe("OAS 3", function() { | ||||
it("allows elements with class, style and data-* attribs", function() { | |||||
const getConfigs = () => ({ useUnsafeMarkdown: true }) | |||||
const str = `<span class="method" style="border-width: 1px" data-attr="value">ONE</span>` | |||||
const el = render(<OAS3Markdown source={str} getConfigs={getConfigs} />) | |||||
expect(el.html()).toEqual(`<div class="renderedMarkdown"><p><span data-attr="value" style="border-width: 1px" class="method">ONE</span></p></div>`) | |||||
}) | |||||
it("strips class, style and data-* attribs from elements", function() { | |||||
const getConfigs = () => ({ useUnsafeMarkdown: false }) | |||||
const str = `<span class="method" style="border-width: 1px" data-attr="value">ONE</span>` | |||||
const el = render(<OAS3Markdown source={str} getConfigs={getConfigs} />) | |||||
expect(el.html()).toEqual(`<div class="renderedMarkdown"><p><span>ONE</span></p></div>`) | |||||
}) | |||||
it("allows image elements", function() { | it("allows image elements", function() { | ||||
const str = `![Image alt text](http://image.source "Image title")` | const str = `![Image alt text](http://image.source "Image title")` | ||||
const el = render(<OAS3Markdown source={str} />) | const el = render(<OAS3Markdown source={str} />) | ||||