Skip to content

Commit 1e14736

Browse files
Add Sanitize API (#349)
* #50597: add deps * #50597: add sanitizer API * #50597: migrate to multipart request * #50597: revert makefile change * #50597: fix proto * #50597: fix dependency * #50597: fix deps * remove stale type, remove `V2` suffix * extract sanitizer-related types * remove `/render` prefix from `/render/sanitize`
1 parent bf31d02 commit 1e14736

File tree

9 files changed

+647
-12
lines changed

9 files changed

+647
-12
lines changed

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,18 @@
3737
"puppeteer": "^13.1.3",
3838
"puppeteer-cluster": "^0.22.0",
3939
"unique-filename": "^1.1.0",
40-
"winston": "^3.2.1"
40+
"winston": "^3.2.1",
41+
"jsdom": "19.0.0",
42+
"dompurify": "^2.3.8",
43+
"multer": "^1.4.5-lts.1"
4144
},
4245
"devDependencies": {
4346
"@grafana/eslint-config": "^2.5.0",
47+
"@types/multer": "1.4.7",
4448
"@types/express": "^4.11.1",
4549
"@types/node": "^14.14.41",
50+
"@types/dompurify": "^2.3.3",
51+
"@types/jsdom": "16.2.14",
4652
"@typescript-eslint/eslint-plugin": "^4.32.0",
4753
"@typescript-eslint/parser": "^4.32.0",
4854
"axios": "0.26.0",

proto/sanitizer.proto

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
syntax = "proto3";
2+
package pluginextensionv2;
3+
4+
option go_package = ".;pluginextensionv2";
5+
6+
message SanitizeRequest {
7+
string filename = 1;
8+
bytes content = 2;
9+
string configType = 3; // DOMPurify, ...
10+
bytes config = 4;
11+
}
12+
13+
message SanitizeResponse {
14+
string error = 1;
15+
bytes sanitized = 2;
16+
}
17+
18+
service Sanitizer {
19+
rpc Sanitize(SanitizeRequest) returns (SanitizeResponse);
20+
}

src/app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ConsoleLogger, PluginLogger } from './logger';
77
import * as minimist from 'minimist';
88
import { defaultPluginConfig, defaultServiceConfig, readJSONFileSync, PluginConfig, ServiceConfig } from './config';
99
import { serve } from './node-plugin';
10+
import { createSanitizer } from './sanitizer/Sanitizer';
1011

1112
const chromeFolderPrefix = 'chrome-';
1213

@@ -67,7 +68,9 @@ async function main() {
6768
populateServiceConfigFromEnv(config, env);
6869

6970
const logger = new ConsoleLogger(config.service.logging);
70-
const server = new HttpServer(config, logger);
71+
72+
const sanitizer = createSanitizer();
73+
const server = new HttpServer(config, logger, sanitizer);
7174
await server.start();
7275
} else {
7376
console.log('Unknown command');

src/plugin/v2/grpc_plugin.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
CollectMetricsRequest,
1717
CollectMetricsResponse,
1818
HealthStatus,
19+
GRPCSanitizeRequest,
20+
GRPCSanitizeResponse,
1921
} from './types';
22+
import { createSanitizer, Sanitizer } from '../../sanitizer/Sanitizer';
23+
import { SanitizeRequest } from '../../sanitizer/types';
2024

2125
const rendererV2PackageDef = protoLoader.loadSync(__dirname + '/../../../proto/rendererv2.proto', {
2226
keepCase: true,
@@ -34,8 +38,17 @@ const pluginV2PackageDef = protoLoader.loadSync(__dirname + '/../../../proto/plu
3438
oneofs: true,
3539
});
3640

41+
const sanitizerPackageDef = protoLoader.loadSync(__dirname + '/../../../proto/sanitizer.proto', {
42+
keepCase: true,
43+
longs: String,
44+
enums: String,
45+
defaults: true,
46+
oneofs: true,
47+
});
48+
3749
const rendererV2ProtoDescriptor = grpc.loadPackageDefinition(rendererV2PackageDef);
3850
const pluginV2ProtoDescriptor = grpc.loadPackageDefinition(pluginV2PackageDef);
51+
const sanitizerProtoDescriptor = grpc.loadPackageDefinition(sanitizerPackageDef);
3952

4053
export class RenderGRPCPluginV2 implements GrpcPlugin {
4154
constructor(private config: PluginConfig, private log: Logger) {
@@ -45,14 +58,17 @@ export class RenderGRPCPluginV2 implements GrpcPlugin {
4558
async grpcServer(server: grpc.Server) {
4659
const metrics = setupMetrics();
4760
const browser = createBrowser(this.config.rendering, this.log, metrics);
48-
const pluginService = new PluginGRPCServer(browser, this.log);
61+
const pluginService = new PluginGRPCServer(browser, this.log, createSanitizer());
4962

5063
const rendererServiceDef = rendererV2ProtoDescriptor['pluginextensionv2']['Renderer']['service'];
5164
server.addService(rendererServiceDef, pluginService as any);
5265

5366
const pluginServiceDef = pluginV2ProtoDescriptor['pluginv2']['Diagnostics']['service'];
5467
server.addService(pluginServiceDef, pluginService as any);
5568

69+
const sanitizerServiceDef = sanitizerProtoDescriptor['pluginextensionv2']['Sanitizer']['service'];
70+
server.addService(sanitizerServiceDef, pluginService as any);
71+
5672
metrics.up.set(1);
5773

5874
let browserVersion = 'unknown';
@@ -76,7 +92,7 @@ export class RenderGRPCPluginV2 implements GrpcPlugin {
7692
class PluginGRPCServer {
7793
private browserVersion: string | undefined;
7894

79-
constructor(private browser: Browser, private log: Logger) {}
95+
constructor(private browser: Browser, private log: Logger, private sanitizer: Sanitizer) {}
8096

8197
async start(browserVersion?: string) {
8298
this.browserVersion = browserVersion;
@@ -178,6 +194,26 @@ class PluginGRPCServer {
178194
const payload = Buffer.from(promClient.register.metrics());
179195
callback(null, { metrics: { prometheus: payload } });
180196
}
197+
198+
async sanitize(call: grpc.ServerUnaryCall<GRPCSanitizeRequest, any>, callback: grpc.sendUnaryData<GRPCSanitizeResponse>) {
199+
const grpcReq = call.request;
200+
201+
const req: SanitizeRequest = {
202+
content: grpcReq.content,
203+
config: JSON.parse(grpcReq.config.toString()),
204+
configType: grpcReq.configType,
205+
};
206+
207+
this.log.debug('Sanitize request received', 'contentLength', req.content.length, 'name', grpcReq.filename);
208+
209+
try {
210+
const sanitizeResponse = this.sanitizer.sanitize(req);
211+
callback(null, { error: '', sanitized: sanitizeResponse.sanitized });
212+
} catch (e) {
213+
this.log.error('Sanitization failed', 'contentLength', req.content.length, 'name', grpcReq.filename, 'error', e.stack);
214+
callback(null, { error: e.stack, sanitized: Buffer.from('', 'binary') });
215+
}
216+
}
181217
}
182218

183219
const populateConfigFromEnv = (config: PluginConfig) => {

src/plugin/v2/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ConfigType } from '../../sanitizer/types';
2+
13
export interface StringList {
24
values: string[];
35
}
@@ -63,3 +65,16 @@ export interface CheckHealthResponse {
6365
message?: string;
6466
jsonDetails?: Buffer;
6567
}
68+
69+
export interface GRPCSanitizeRequest {
70+
filename: string;
71+
content: Buffer;
72+
configType: ConfigType;
73+
config: Buffer;
74+
allowAllLinksInSvgUseTags: boolean;
75+
}
76+
77+
export interface GRPCSanitizeResponse {
78+
error: string;
79+
sanitized: Buffer;
80+
}

src/sanitizer/Sanitizer.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as DOMPurify from 'dompurify';
2+
import { JSDOM } from 'jsdom';
3+
import { ConfigType, isDOMPurifyConfig, SanitizeRequest, SanitizeResponse } from './types';
4+
5+
const svgTags = {
6+
altGlyphDef: /(<\/?)altGlyphDef([> ])/gi,
7+
altGlyphItem: /(<\/?)altGlyphItem([> ])/gi,
8+
altGlyph: /(<\/?)altGlyph([> ])/gi,
9+
animateColor: /(<\/?)animateColor([> ])/gi,
10+
animateMotion: /(<\/?)animateMotion([> ])/gi,
11+
animateTransform: /(<\/?)animateTransform([> ])/gi,
12+
clipPath: /(<\/?)clipPath([> ])/gi,
13+
feBlend: /(<\/?)feBlend([> ])/gi,
14+
feColorMatrix: /(<\/?)feColorMatrix([> ])/gi,
15+
feComponentTransfer: /(<\/?)feComponentTransfer([> ])/gi,
16+
feComposite: /(<\/?)feComposite([> ])/gi,
17+
feConvolveMatrix: /(<\/?)feConvolveMatrix([> ])/gi,
18+
feDiffuseLighting: /(<\/?)feDiffuseLighting([> ])/gi,
19+
feDisplacementMap: /(<\/?)feDisplacementMap([> ])/gi,
20+
feDistantLight: /(<\/?)feDistantLight([> ])/gi,
21+
feDropShadow: /(<\/?)feDropShadow([> ])/gi,
22+
feFlood: /(<\/?)feFlood([> ])/gi,
23+
feFuncA: /(<\/?)feFuncA([> ])/gi,
24+
feFuncB: /(<\/?)feFuncB([> ])/gi,
25+
feFuncG: /(<\/?)feFuncG([> ])/gi,
26+
feFuncR: /(<\/?)feFuncR([> ])/gi,
27+
feGaussianBlur: /(<\/?)feGaussianBlur([> ])/gi,
28+
feImage: /(<\/?)feImage([> ])/gi,
29+
feMergeNode: /(<\/?)feMergeNode([> ])/gi,
30+
feMerge: /(<\/?)feMerge([> ])/gi,
31+
feMorphology: /(<\/?)feMorphology([> ])/gi,
32+
feOffset: /(<\/?)feOffset([> ])/gi,
33+
fePointLight: /(<\/?)fePointLight([> ])/gi,
34+
feSpecularLighting: /(<\/?)feSpecularLighting([> ])/gi,
35+
feSpotLight: /(<\/?)feSpotLight([> ])/gi,
36+
feTile: /(<\/?)feTile([> ])/gi,
37+
feTurbulence: /(<\/?)feTurbulence([> ])/gi,
38+
foreignObject: /(<\/?)foreignObject([> ])/gi,
39+
glyphRef: /(<\/?)glyphRef([> ])/gi,
40+
linearGradient: /(<\/?)linearGradient([> ])/gi,
41+
radialGradient: /(<\/?)radialGradient([> ])/gi,
42+
textPath: /(<\/?)textPath([> ])/gi,
43+
};
44+
45+
const svgFilePrefix = '<?xml version="1.0" encoding="utf-8"?>';
46+
47+
export class Sanitizer {
48+
constructor(private domPurify: DOMPurify.DOMPurifyI) {}
49+
50+
private sanitizeUseTagHook = (node) => {
51+
if (node.nodeName === 'use') {
52+
if (
53+
(node.hasAttribute('xlink:href') && !node.getAttribute('xlink:href').match(/^#/)) ||
54+
(node.hasAttribute('href') && !node.getAttribute('href').match(/^#/))
55+
) {
56+
node.remove();
57+
}
58+
}
59+
};
60+
61+
private sanitizeSvg = (req: SanitizeRequest<ConfigType.DOMPurify>): SanitizeResponse => {
62+
if (req.config.allowAllLinksInSvgUseTags !== true) {
63+
this.domPurify.addHook('afterSanitizeAttributes', this.sanitizeUseTagHook);
64+
}
65+
66+
const dirty = req.content.toString();
67+
let sanitized = this.domPurify.sanitize(dirty, req.config.domPurifyConfig ?? {}) as string;
68+
69+
// ensure tags have the correct capitalization, as dompurify converts them to lowercase
70+
Object.entries(svgTags).forEach(([regex, tag]) => {
71+
sanitized = sanitized.replace(regex, '$1' + tag + '$2');
72+
});
73+
74+
this.domPurify.removeHook('afterSanitizeAttributes');
75+
return { sanitized: Buffer.from([svgFilePrefix, sanitized].join('\n')) };
76+
};
77+
78+
sanitize = (req: SanitizeRequest): SanitizeResponse => {
79+
const configType = req.configType;
80+
if (!isDOMPurifyConfig(req)) {
81+
throw new Error('unsupported config type: ' + configType);
82+
}
83+
84+
if (req.config.domPurifyConfig?.USE_PROFILES?.['svg']) {
85+
return this.sanitizeSvg(req);
86+
}
87+
88+
const dirty = req.content.toString();
89+
const sanitized = this.domPurify.sanitize(dirty, req.config.domPurifyConfig ?? {}) as string;
90+
return {
91+
sanitized: Buffer.from(sanitized),
92+
};
93+
};
94+
}
95+
96+
export const createSanitizer = () => {
97+
return new Sanitizer(DOMPurify(new JSDOM('').window as any));
98+
};

src/sanitizer/types.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as DOMPurify from 'dompurify';
2+
3+
export enum ConfigType {
4+
DOMPurify = 'DOMPurify',
5+
}
6+
7+
export const isDOMPurifyConfig = (req: SanitizeRequest): req is SanitizeRequest<ConfigType.DOMPurify> => req.configType === ConfigType.DOMPurify;
8+
9+
const allConfigTypes = Object.values(ConfigType);
10+
11+
export type ConfigTypeToConfig = {
12+
[ConfigType.DOMPurify]: {
13+
domPurifyConfig?: DOMPurify.Config;
14+
allowAllLinksInSvgUseTags?: boolean;
15+
};
16+
};
17+
18+
export const isSanitizeRequest = (obj: any): obj is SanitizeRequest => {
19+
return Boolean(obj?.content) && allConfigTypes.includes(obj.configType) && typeof obj.config === 'object';
20+
};
21+
22+
export type SanitizeRequest<configType extends ConfigType = ConfigType> = {
23+
content: Buffer;
24+
configType: configType;
25+
config: ConfigTypeToConfig[configType];
26+
};
27+
28+
export type SanitizeResponse = {
29+
sanitized: Buffer;
30+
};

src/service/http-server.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,23 @@ import { Browser, createBrowser } from '../browser';
1010
import { ServiceConfig } from '../config';
1111
import { setupHttpServerMetrics } from './metrics_middleware';
1212
import { HTTPHeaders, ImageRenderOptions, RenderOptions } from '../types';
13+
import { Sanitizer } from '../sanitizer/Sanitizer';
14+
import * as bodyParser from 'body-parser';
15+
import * as multer from 'multer';
16+
import { isSanitizeRequest } from '../sanitizer/types';
17+
18+
const upload = multer({ storage: multer.memoryStorage() });
19+
20+
enum SanitizeRequestPartName {
21+
'file' = 'file',
22+
'config' = 'config',
23+
}
1324

1425
export class HttpServer {
1526
app: express.Express;
1627
browser: Browser;
1728

18-
constructor(private config: ServiceConfig, private log: Logger) {}
29+
constructor(private config: ServiceConfig, private log: Logger, private sanitizer: Sanitizer) {}
1930

2031
async start() {
2132
this.app = express();
@@ -36,6 +47,8 @@ export class HttpServer {
3647
})
3748
);
3849

50+
this.app.use(bodyParser.json());
51+
3952
if (this.config.service.metrics.enabled) {
4053
setupHttpServerMetrics(this.app, this.config.service.metrics, this.log);
4154
}
@@ -49,6 +62,14 @@ export class HttpServer {
4962

5063
this.app.get('/render', asyncMiddleware(this.render));
5164
this.app.get('/render/csv', asyncMiddleware(this.renderCSV));
65+
this.app.post(
66+
'/sanitize',
67+
upload.fields([
68+
{ name: SanitizeRequestPartName.file, maxCount: 1 },
69+
{ name: SanitizeRequestPartName.config, maxCount: 1 },
70+
]),
71+
asyncMiddleware(this.sanitize)
72+
);
5273
this.app.use((err, req, res, next) => {
5374
if (err.stack) {
5475
this.log.error('Request failed', 'url', req.url, 'stack', err.stack);
@@ -146,6 +167,44 @@ export class HttpServer {
146167
});
147168
};
148169

170+
sanitize = async (req: express.Request<any, { error: string }>, res: express.Response<{ error: string }>) => {
171+
const file = req.files?.[SanitizeRequestPartName.file]?.[0] as Express.Multer.File | undefined;
172+
if (!file) {
173+
throw boom.badRequest('missing file');
174+
}
175+
176+
const configFile = req.files?.[SanitizeRequestPartName.config]?.[0] as Express.Multer.File | undefined;
177+
if (!configFile) {
178+
throw boom.badRequest('missing config');
179+
}
180+
181+
const config = JSON.parse(configFile.buffer.toString());
182+
183+
const sanitizeReq = {
184+
...config,
185+
content: file.buffer,
186+
};
187+
188+
if (!isSanitizeRequest(sanitizeReq)) {
189+
throw boom.badRequest('invalid request: ' + JSON.stringify(config));
190+
}
191+
192+
this.log.debug('Sanitize request received', 'contentLength', file.size, 'name', file.filename, 'config', JSON.stringify(config));
193+
194+
try {
195+
const sanitizeResponse = this.sanitizer.sanitize(sanitizeReq);
196+
res.writeHead(200, {
197+
'Content-Disposition': `attachment;filename=${file.filename ?? 'sanitized'}`,
198+
'Content-Length': sanitizeResponse.sanitized.length,
199+
'Content-Type': file.mimetype ?? 'application/octet-stream',
200+
});
201+
return res.end(sanitizeResponse.sanitized);
202+
} catch (e) {
203+
this.log.error('Sanitization failed', 'filesize', file.size, 'name', file.filename, 'error', e.stack);
204+
return res.status(500).json({ error: e.message });
205+
}
206+
};
207+
149208
renderCSV = async (req: express.Request<any, any, any, RenderOptions, any>, res: express.Response, next: express.NextFunction) => {
150209
if (!req.query.url) {
151210
throw boom.badRequest('Missing url parameter');

0 commit comments

Comments
 (0)