Skip to content

Commit 0400fa0

Browse files
Implement security restriction options to allow limiting demo app
1 parent 4b15a12 commit 0400fa0

File tree

6 files changed

+106
-7
lines changed

6 files changed

+106
-7
lines changed

README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,12 @@ and requests are direct connections to it.
5959

6060
## Examples
6161

62-
*Note: the demo Heroku app runs on a free dyno which sleep after idle.
63-
A request to sleeping dyno may take even 30 seconds.*
62+
**⚠️ Restrictions ⚠️:**
63+
64+
* For security reasons the urls have been restricted and HTML rendering is disabled. For full demo, run this app locally or deploy to Heroku.
65+
* The demo Heroku app runs on a free dyno which sleep after idle. A request to sleeping dyno may take even 30 seconds.
66+
67+
6468

6569
**The most minimal example, render google.com**
6670

@@ -106,14 +110,18 @@ https://url-to-pdf-api.herokuapp.com/api/render?url=http://google.com&waitFor=in
106110

107111
**Render HTML sent in JSON body**
108112

113+
*NOTE: Demo app has disabled html rendering for security reasons.*
114+
109115
```bash
110-
curl -o html.pdf -XPOST -d'{"html": "<body>test</body>"}' -H"content-type: application/json" https://url-to-pdf-api.herokuapp.com/api/render
116+
curl -o html.pdf -XPOST -d'{"html": "<body>test</body>"}' -H"content-type: application/json" http://localhost:9000/api/render
111117
```
112118

113119
**Render HTML sent as text body**
114120

121+
*NOTE: Demo app has disabled html rendering for security reasons.*
122+
115123
```bash
116-
curl -o html.pdf -XPOST -d@page.html -H"content-type: text/html" https://url-to-pdf-api.herokuapp.com/api/render
124+
curl -o html.pdf -XPOST -d@test/resources/large.html -H"content-type: text/html" http://localhost:9000/api/render
117125
```
118126

119127
## API
@@ -264,11 +272,11 @@ The only required parameter is `url`.
264272
**Example:**
265273

266274
```bash
267-
curl -o google.pdf -XPOST -d'{"url": "http://google.com"}' -H"content-type: application/json" https://url-to-pdf-api.herokuapp.com/api/render
275+
curl -o google.pdf -XPOST -d'{"url": "http://google.com"}' -H"content-type: application/json" http://localhost:9000/api/render
268276
```
269277

270278
```bash
271-
curl -o html.pdf -XPOST -d'{"html": "<body>test</body>"}' -H"content-type: application/json" https://url-to-pdf-api.herokuapp.com/api/render
279+
curl -o html.pdf -XPOST -d'{"html": "<body>test</body>"}' -H"content-type: application/json" http://localhost:9000/api/render
272280
```
273281

274282
### POST /api/render - (HTML)
@@ -283,7 +291,7 @@ paremeter.
283291

284292
```bash
285293
curl -o receipt.html https://rawgit.com/wildbit/postmark-templates/master/templates_inlined/receipt.html
286-
curl -o html.pdf -XPOST [email protected] -H"content-type: text/html" https://url-to-pdf-api.herokuapp.com/api/render?pdf.scale=1
294+
curl -o html.pdf -XPOST [email protected] -H"content-type: text/html" http://localhost:9000/api/render?pdf.scale=1
287295
```
288296

289297
## Development

package-lock.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"joi": "^11.1.1",
3232
"lodash": "^4.17.15",
3333
"morgan": "^1.9.1",
34+
"normalize-url": "^5.0.0",
3435
"pdf-parse": "^1.1.1",
3536
"puppeteer": "^2.0.0",
3637
"server-destroy": "^1.0.1",

src/app.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ function createApp() {
2828
logger.info('ALLOW_HTTP=true, unsafe requests are allowed. Don\'t use this in production.');
2929
}
3030

31+
if (config.ALLOW_URLS) {
32+
logger.info(`ALLOW_URLS set! Allowed urls patterns are: ${config.ALLOW_URLS.join(' ')}`);
33+
}
34+
35+
if (config.DISABLE_HTML_INPUT) {
36+
logger.info('DISABLE_HTML_INPUT=true! Input HTML is disabled!');
37+
}
38+
3139
const corsOpts = {
3240
origin: config.CORS_ORIGIN,
3341
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'],

src/config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@ const config = {
77
LOG_LEVEL: process.env.LOG_LEVEL,
88
ALLOW_HTTP: process.env.ALLOW_HTTP === 'true',
99
DEBUG_MODE: process.env.DEBUG_MODE === 'true',
10+
DISABLE_HTML_INPUT: process.env.DISABLE_HTML_INPUT === 'true',
1011
CORS_ORIGIN: process.env.CORS_ORIGIN || '*',
1112
BROWSER_WS_ENDPOINT: process.env.BROWSER_WS_ENDPOINT,
1213
BROWSER_EXECUTABLE_PATH: process.env.BROWSER_EXECUTABLE_PATH,
1314
API_TOKENS: [],
15+
ALLOW_URLS: [],
1416
};
1517

1618
if (process.env.API_TOKENS) {
1719
config.API_TOKENS = process.env.API_TOKENS.split(',');
1820
}
1921

22+
if (process.env.ALLOW_URLS) {
23+
config.ALLOW_URLS = process.env.ALLOW_URLS.split(',');
24+
}
25+
2026
module.exports = config;

src/http/render-http.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
const { URL } = require('url');
12
const _ = require('lodash');
3+
const normalizeUrl = require('normalize-url');
24
const ex = require('../util/express');
35
const renderCore = require('../core/render-core');
6+
const logger = require('../util/logger')(__filename);
7+
const config = require('../config');
48

59
function getMimeType(opts) {
610
if (opts.output === 'pdf') {
@@ -19,6 +23,8 @@ function getMimeType(opts) {
1923

2024
const getRender = ex.createRoute((req, res) => {
2125
const opts = getOptsFromQuery(req.query);
26+
27+
assertOptionsAllowed(opts);
2228
return renderCore.render(opts)
2329
.then((data) => {
2430
if (opts.attachmentName) {
@@ -53,6 +59,7 @@ const postRender = ex.createRoute((req, res) => {
5359
opts.html = req.body;
5460
}
5561

62+
assertOptionsAllowed(opts);
5663
return renderCore.render(opts)
5764
.then((data) => {
5865
if (opts.attachmentName) {
@@ -63,6 +70,70 @@ const postRender = ex.createRoute((req, res) => {
6370
});
6471
});
6572

73+
function isHostMatch(host1, host2) {
74+
return {
75+
match: host1.toLowerCase() === host2.toLowerCase(),
76+
type: 'host',
77+
part1: host1.toLowerCase(),
78+
part2: host2.toLowerCase(),
79+
};
80+
}
81+
82+
function isRegexMatch(urlPattern, inputUrl) {
83+
const re = new RegExp(`${urlPattern}`);
84+
85+
return {
86+
match: re.test(inputUrl),
87+
type: 'regex',
88+
part1: inputUrl,
89+
part2: urlPattern,
90+
};
91+
}
92+
93+
function isNormalizedMatch(url1, url2) {
94+
return {
95+
match: normalizeUrl(url1) === normalizeUrl(url2),
96+
type: 'normalized url',
97+
part1: url1,
98+
part2: url2,
99+
};
100+
}
101+
102+
function isUrlAllowed(inputUrl) {
103+
const urlParts = new URL(inputUrl);
104+
105+
const matchInfos = _.map(config.ALLOW_URLS, (urlPattern) => {
106+
if (_.startsWith(urlPattern, 'host:')) {
107+
return isHostMatch(urlPattern.split(':')[1], urlParts.host);
108+
} else if (_.startsWith(urlPattern, 'regex:')) {
109+
return isRegexMatch(urlPattern.split(':')[1], inputUrl);
110+
}
111+
112+
return isNormalizedMatch(urlPattern, inputUrl);
113+
});
114+
115+
const isAllowed = _.some(matchInfos, info => info.match);
116+
if (!isAllowed) {
117+
logger.info('The url was not allowed because:');
118+
_.forEach(matchInfos, (info) => {
119+
logger.info(`${info.part1} !== ${info.part2} (with ${info.type} matching)`);
120+
});
121+
}
122+
123+
return isAllowed;
124+
}
125+
126+
function assertOptionsAllowed(opts) {
127+
const isDisallowedHtmlInput = !_.isString(opts.url) && config.DISABLE_HTML_INPUT;
128+
if (isDisallowedHtmlInput) {
129+
ex.throwStatus(403, 'Rendering HTML input is disabled.');
130+
}
131+
132+
if (_.isString(opts.url) && config.ALLOW_URLS.length > 0 && !isUrlAllowed(opts.url)) {
133+
ex.throwStatus(403, 'Url not allowed.');
134+
}
135+
}
136+
66137
function getOptsFromQuery(query) {
67138
const opts = {
68139
url: query.url,

0 commit comments

Comments
 (0)