Skip to content

Commit a2b6c1e

Browse files
committed
Carefully escape request & response data in performance explanations
Previously, a carefully crafted header could plausibly have injected a confusing message here like (click here for more info [link]) using an HTML link or an autolinked URL, and maybe used this for phishing or something. Very limited risk imo but definitely good to protect against properly. We now filter all active markdown (even bold etc) from values shown here, and disable autolinking for all those cases.
1 parent f9c95c4 commit a2b6c1e

File tree

7 files changed

+191
-47
lines changed

7 files changed

+191
-47
lines changed

src/components/common/text-content.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,15 @@ export const Content = styled.div`
144144
}
145145
`;
146146

147-
export const Markdown = (p: { content: string | undefined }) =>
147+
export const Markdown = (p: {
148+
content: string | undefined,
149+
linkify?: boolean
150+
}) =>
148151
p.content ?
149152
<ExternalContent htmlContent={fromMarkdown(
150153
p.content
151154
.replace(/:suggestion:/g, suggestionIconHtml)
152-
.replace(/:warning:/g, warningIconHtml)
155+
.replace(/:warning:/g, warningIconHtml),
156+
{ linkify: p.linkify }
153157
)} />
154158
: null;

src/model/api/jsonrpc.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export class JsonRpcApiService implements ApiService {
130130
?? this.name.split(' ')[0];
131131

132132
this.logoUrl = api.spec.info['x-logo']?.url;
133-
this.description = fromMarkdown(api.spec.info.description);
133+
this.description = fromMarkdown(api.spec.info.description, { linkify: true });
134134
this.docsUrl = api.spec.externalDocs?.url;
135135
}
136136

@@ -154,7 +154,7 @@ export class JsonRpcApiOperation implements ApiOperation {
154154
this.description = fromMarkdown([
155155
methodSpec.summary,
156156
methodSpec.description
157-
].filter(x => !!x).join('\n\n'));
157+
].filter(x => !!x).join('\n\n'), { linkify: true });
158158
this.docsUrl = methodSpec.externalDocs?.url
159159
?? (methodDocsBaseUrl
160160
? methodDocsBaseUrl + methodSpec.name.toLowerCase()
@@ -204,7 +204,7 @@ export class JsonRpcApiRequest implements ApiRequest {
204204
]
205205
: []
206206
)
207-
].filter(x => !!x).join('\n\n')),
207+
].filter(x => !!x).join('\n\n'), { linkify: true }),
208208
in: 'body',
209209
required: !!param.required,
210210
deprecated: !!param.deprecated,
@@ -234,7 +234,7 @@ export class JsonRpcApiResponse implements ApiResponse {
234234
constructor(rpcMethod: MatchedOperation) {
235235
const resultSpec = rpcMethod.methodSpec.result as ContentDescriptorObject;
236236

237-
this.description = fromMarkdown(resultSpec.description);
237+
this.description = fromMarkdown(resultSpec.description, { linkify: true });
238238
this.bodySchema = {
239239
type: 'object',
240240
properties: {

src/model/api/openapi.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function getParameters(
103103
specParam: param,
104104
name: param.name,
105105
in: param.in,
106-
description: fromMarkdown(param.description),
106+
description: fromMarkdown(param.description, { linkify: true }),
107107
required: param.required || param.in === 'path',
108108
type: schema && schema.type,
109109
defaultValue: schema && schema.default,
@@ -333,7 +333,7 @@ class OpenApiService implements ApiService {
333333
?? this.name.split(' ')[0];
334334

335335
this.logoUrl = service['x-logo']?.url;
336-
this.description = fromMarkdown(service.description);
336+
this.description = fromMarkdown(service.description, { linkify: true });
337337
this.docsUrl = spec?.externalDocs?.url;
338338
}
339339

@@ -383,14 +383,15 @@ class OpenApiOperation implements ApiOperation {
383383
() => (get(op.spec, 'description', 'length') || Infinity) < 40, op.spec.description!
384384
],
385385
op.pathSpec.summary
386-
) || `${op.method.toUpperCase()} ${op.path}`
386+
) || `${op.method.toUpperCase()} ${op.path}`,
387+
{ linkify: true }
387388
).__html);
388389

389390
this.description = fromMarkdown(firstMatch<string>(
390391
[() => get(op.spec, 'description') !== this.name, get(op.spec, 'description')],
391392
[() => get(op.spec, 'summary') !== this.name, get(op.spec, 'summary')],
392393
op.pathSpec.description
393-
));
394+
), { linkify: true });
394395

395396
if (!op.matched) this.warnings.push(
396397
`Unknown operation '${this.name}'.`
@@ -449,7 +450,7 @@ class OpenApiResponse implements ApiResponse {
449450
: undefined;
450451

451452
this.description = bodySpec && bodySpec.description !== response.statusMessage
452-
? fromMarkdown(bodySpec.description)
453+
? fromMarkdown(bodySpec.description, { linkify: true })
453454
: undefined;
454455
this.bodySchema = getBodySchema(spec, bodySpec, response);
455456
}

src/model/http/caching.ts

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { HttpExchange, ExchangeMessage } from '../../types';
1111
import { lastHeader, asHeaderArray } from '../../util/headers';
1212
import { joinAnd } from '../../util/text';
13+
import { escapeForMarkdownEmbedding } from '../ui/markdown';
1314

1415
// https://tools.ietf.org/html/draft-ietf-httpbis-semantics-04#section-7.2.3
1516
const CACHEABLE_METHODS = ['GET', 'HEAD', 'POST'];
@@ -53,7 +54,13 @@ const CORS_SIMPLE_HEADERS = [
5354

5455
function formatHeader(headerName: string): string {
5556
// Upper case the first letter of each word (including hyphenated parts)
56-
return headerName.toLowerCase().replace(/(\b\w)/g, v => v.toUpperCase())
57+
return headerName.toLowerCase().replace(/(\b\w)/g, v => v.toUpperCase());
58+
}
59+
60+
function escapeAndFormatHeader(headerName: string): string {
61+
return `<code>${escapeForMarkdownEmbedding(
62+
formatHeader(headerName)
63+
)}</code>`;
5764
}
5865

5966
const THE_DAWN_OF_TIME = parseDate(0);
@@ -126,9 +133,10 @@ export function explainCacheability(exchange: HttpExchange): (
126133
mechanisms, but the CORS result itself can be cached if a
127134
Access-Control-Max-Age header is provided.
128135
129-
In this case that header is set to ${maxAgeHeader}, explicitly
130-
requesting that this result should not be cached, and that clients
131-
should not reuse this CORS response in future.
136+
In this case that header is set to ${escapeForMarkdownEmbedding(
137+
maxAgeHeader!
138+
)}, explicitly requesting that this result should not be cached,
139+
and that clients should not reuse this CORS response in future.
132140
`
133141
};
134142
}
@@ -146,7 +154,9 @@ export function explainCacheability(exchange: HttpExchange): (
146154
return {
147155
cacheable: false,
148156
summary: 'Not cacheable',
149-
explanation: `${request.method} requests are never cacheable.`
157+
explanation: `${escapeForMarkdownEmbedding(
158+
request.method
159+
)} requests are never cacheable.`
150160
};
151161
}
152162
}
@@ -550,7 +560,7 @@ export function explainCacheMatching(exchange: HttpExchange): Explanation | unde
550560
const allowedHeaders = _.union(
551561
CORS_SIMPLE_HEADERS,
552562
asHeaderArray(response.headers['access-control-allow-headers'])
553-
.map(formatHeader)
563+
.map(escapeAndFormatHeader)
554564
);
555565
const allowsCredentials = response.headers['Access-Control-Allow-Credentials'] === 'true';
556566

@@ -561,37 +571,44 @@ export function explainCacheMatching(exchange: HttpExchange): Explanation | unde
561571
request for future CORS requests, when:
562572
563573
* The CORS request would be sent to the same URL
564-
* The origin is \`${request.headers['origin']}\`
574+
* The origin is <code>${escapeForMarkdownEmbedding(
575+
request.headers['origin'].toString()
576+
)}</code>
565577
${!allowsCredentials ? '* No credentials are being sent\n' : ''
566-
}* The request method would be ${joinAnd(allowedMethods, ', ', ' or ')}
578+
}* The request method would be ${escapeForMarkdownEmbedding(
579+
joinAnd(allowedMethods, ', ', ' or ')
580+
)}
567581
* There are no extra request headers other than ${joinAnd(allowedHeaders)}
568582
`
569583
};
570584
}
571585

572-
const varyHeaders = asHeaderArray(response.headers['vary']).map(formatHeader);
573-
574-
const hasVaryHeaders = varyHeaders.length > 0;
586+
const rawVaryHeaders = asHeaderArray(response.headers['vary']);
587+
const hasVaryHeaders = rawVaryHeaders.length > 0;
575588

576589
// Don't need to handle Vary: *, as that would've excluded cacheability entirely.
577590

578591
const varySummary = hasVaryHeaders ?
579592
` that have the same ${
580-
joinAnd(varyHeaders)
581-
} header${varyHeaders.length > 1 ? 's' : ''}`
593+
joinAnd(rawVaryHeaders.map(h => `'${formatHeader(h)}'`)) // No escaping - summary (non-markdown) only
594+
} header${rawVaryHeaders.length > 1 ? 's' : ''}`
582595
: '';
583596

584597
const varyExplanation = hasVaryHeaders ? dedent`
585-
, as long as those requests have ${joinAnd(varyHeaders.map(headerName => {
598+
, as long as those requests have ${joinAnd(rawVaryHeaders.map(headerName => {
586599
const realHeaderValue = request.headers[headerName.toLowerCase()];
587600
601+
const formattedName = escapeAndFormatHeader(headerName);
602+
588603
return realHeaderValue === undefined ?
589-
`no ${headerName} header` :
590-
`a ${headerName} header set to \`${realHeaderValue}\``
604+
`no ${formattedName} header` :
605+
`a ${formattedName} header set to <code>${escapeForMarkdownEmbedding(
606+
realHeaderValue.toString()
607+
)}</code>`
591608
}))}.
592609
593-
${varyHeaders.length > 1 ? 'These headers are' : 'This header is'}
594-
required because ${varyHeaders.length > 1 ? "they're" : "it's"} listed in
610+
${rawVaryHeaders.length > 1 ? 'These headers are' : 'This header is'}
611+
required because ${rawVaryHeaders.length > 1 ? "they're" : "it's"} listed in
595612
the Vary header of the response.
596613
` : dedent`
597614
, regardless of header values or other factors.
@@ -760,7 +777,9 @@ export function explainCacheLifetime(exchange: HttpExchange): Explanation | unde
760777
some percentage of the time since the content was last modified, often using
761778
the Last-Modified header value${
762779
response.headers['last-modified'] ?
763-
` (${response.headers['last-modified']})`
780+
` (<code>${escapeForMarkdownEmbedding(
781+
response.headers['last-modified'].toString()
782+
)}</code>)`
764783
: ', although that is not explicitly defined in this response either'
765784
}
766785
` :
@@ -770,11 +789,11 @@ export function explainCacheLifetime(exchange: HttpExchange): Explanation | unde
770789
a \`max-age\` directive set to ${maxAge} seconds
771790
` :
772791
dateHeader ? dedent `
773-
an Expires header set to ${expiresHeader}, which is
774-
not after its Date header value (${dateHeader})
792+
an Expires header set to ${escapeForMarkdownEmbedding(expiresHeader!.toString())}, which is
793+
not after its Date header value (${escapeForMarkdownEmbedding(dateHeader)})
775794
`
776795
: dedent`
777-
an Expires header set to ${expiresHeader}, which is
796+
an Expires header set to ${escapeForMarkdownEmbedding(expiresHeader!)}, which is
778797
before the response was received
779798
`
780799
}${explainSharedCacheLifetime}
@@ -784,7 +803,7 @@ export function explainCacheLifetime(exchange: HttpExchange): Explanation | unde
784803
as specified by its \`max-age\` directive${explainSharedCacheLifetime}
785804
`
786805
: dedent`
787-
This response expires at ${expiresHeader} (after ${formatDuration(lifetime!)}),
806+
This response expires at ${escapeForMarkdownEmbedding(expiresHeader!)} (after ${formatDuration(lifetime!)}),
788807
as specified by its Expires header${explainSharedCacheLifetime}
789808
`;
790809

src/model/ui/markdown.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import * as DOMPurify from 'dompurify';
33

44
import { Html } from '../../types';
55

6-
const md = new Remarkable({
6+
const linkedMarkdown = new Remarkable({
77
html: true,
88
linkify: true,
99
linkTarget: '_blank' // Links should always open elsewhere
1010
});
1111

12+
const linklessMarkdown = new Remarkable({
13+
html: true,
14+
linkify: false
15+
});
16+
1217
// Add an extra hook to DOMPurify to enforce link target. Without this, DOMPurify strips
1318
// every link target entirely.
1419
DOMPurify.addHook('afterSanitizeAttributes', function (node: Element | HTMLElement) {
@@ -38,13 +43,39 @@ DOMPurify.addHook('afterSanitizeAttributes', function (node: Element | HTMLEleme
3843
}
3944
});
4045

41-
export function fromMarkdown(input: string): Html;
42-
export function fromMarkdown(input: string | undefined): Html | undefined;
43-
export function fromMarkdown(input: string | undefined): Html | undefined {
46+
export interface MarkdownRenderingOptions {
47+
linkify?: boolean // False by default
48+
}
49+
50+
export function fromMarkdown(input: string, options?: MarkdownRenderingOptions): Html;
51+
export function fromMarkdown(input: string | undefined, options?: MarkdownRenderingOptions): Html | undefined;
52+
export function fromMarkdown(input: string | undefined, options?: MarkdownRenderingOptions): Html | undefined {
4453
if (!input) return undefined;
4554
else {
55+
const md = options?.linkify ? linkedMarkdown : linklessMarkdown;
4656
const unsafeMarkdown = md.render(input).replace(/\n$/, '');
4757
const safeHtml = DOMPurify.sanitize(unsafeMarkdown);
4858
return { __html: safeHtml };
4959
}
60+
}
61+
62+
/**
63+
* Takes an input string, and turns it into a value that will appear the same when
64+
* rendered in markdown (and will not render any active HTML, e.g. links). This
65+
* goes further than DOMPurify above by disabling _all_ non-plain text content.
66+
*
67+
* Important notes:
68+
* - This escapes input for use as content, and doesn't cover cases like
69+
* escaping for HTML attribute values or similar.
70+
* - This cannot fully escape _closing_ backticks - it's impossible to do this in
71+
* markdown, as even \\\` will close a previous \` block. Use &lt;code&gt; instead.
72+
* - If linkify: true is used in later rendering, recognized URLs will still autolink.
73+
*/
74+
export function escapeForMarkdownEmbedding(input: string) {
75+
const htmlEscaped = input
76+
.replace(/&/g, '&amp;')
77+
.replace(/</g, '&lt;')
78+
.replace(/>/g, '&gt;');
79+
const markdownEscaped = htmlEscaped.replace(/([\\`*_{}\[\]()#+\-.!~|])/g, '\\$1');
80+
return markdownEscaped;
5081
}

test/unit/model/http/caching.spec.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -818,11 +818,11 @@ describe('Caching explanations', () => {
818818

819819
it("should explain the detailed CORS matching behaviour", () => {
820820
const result = explainCacheMatching(exchange);
821-
expect(result!.explanation).to.include('The origin is \`http://example2.com\`');
821+
expect(result!.explanation).to.include('The origin is <code>http://example2\\.com</code>');
822822
expect(result!.explanation).to.include(
823823
'The request method would be GET, HEAD, POST or DELETE'
824824
);
825-
expect(result!.explanation).to.include('X-Header');
825+
expect(result!.explanation).to.include('X\\-Header');
826826
});
827827

828828
it("should say when it expires", () => {
@@ -936,14 +936,14 @@ describe('Caching explanations', () => {
936936
it("should include the Vary in the cache key", () => {
937937
const result = explainCacheMatching(exchange);
938938
expect(result!.summary).to.include(
939-
'this URL that have the same Cookie header'
939+
'this URL that have the same \'Cookie\' header'
940940
);
941941
});
942942

943943
it("should explain the Vary in the cache key", () => {
944944
const result = explainCacheMatching(exchange);
945945
expect(result!.explanation).to.include(
946-
'as long as those requests have a Cookie header set to `abc`'
946+
'as long as those requests have a <code>Cookie</code> header set to <code>abc</code>'
947947
);
948948
});
949949
});
@@ -969,16 +969,17 @@ describe('Caching explanations', () => {
969969

970970
it("should include the Vary in the cache key", () => {
971971
const result = explainCacheMatching(exchange);
972-
expect(result!.summary).to.include(
973-
'this URL that have the same Cookie, Accept-Language and Accept headers'
974-
);
972+
expect(result!.summary).to.include(dedent`
973+
this URL that have the same 'Cookie', 'Accept-Language'
974+
and 'Accept' headers
975+
`.replace(/\n/g, ' '));
975976
});
976977

977978
it("should explain the Vary in the cache key", () => {
978979
const result = explainCacheMatching(exchange);
979980
expect(result!.explanation).to.include(dedent`
980-
as long as those requests have a Cookie header set to \`abc\`,
981-
a Accept-Language header set to \`en\` and no Accept header
981+
as long as those requests have a <code>Cookie</code> header set to <code>abc</code>,
982+
a <code>Accept\-Language</code> header set to <code>en</code> and no <code>Accept</code> header
982983
`.replace(/\n/g, ' '));
983984
});
984985
});

0 commit comments

Comments
 (0)