Skip to content

Commit a926332

Browse files
committed
chore: update dependencies to latest versions for express, multer, and puppeteer
1 parent 172c414 commit a926332

6 files changed

Lines changed: 1042 additions & 751 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ docker run -p 8080:3000 lucientes
1919
# With API key protection
2020
docker run -p 8080:3000 -e API_KEY=secret lucientes
2121

22+
# With JavaScript enabled
23+
docker run -p 8080:3000 -e API_KEY=secret -e ALLOW_JAVASCRIPT=true lucientes
24+
2225
# Test endpoints
2326
curl -X POST http://localhost:8080/html-to-image -F "file=@test.html" --output out.png
2427
curl -X POST http://localhost:8080/html-to-pdf -F "file=@test.html" --output out.pdf
@@ -39,6 +42,13 @@ PDF generation applies a DPI correction factor (96/72) to compensate for Puppete
3942
- `PORT` - server port (default 3000)
4043
- `API_KEY` - if set, requires `x-api-key` header on all requests
4144

45+
### Security Settings
46+
47+
- `ALLOW_JAVASCRIPT` - set to "true" to enable JS execution in HTML (default: disabled)
48+
- `ALLOW_EXTERNAL_REQUESTS` - set to "true" to allow network requests from rendered pages (default: blocked)
49+
- `MAX_DIMENSION` - maximum width/height in pixels (default: 4096)
50+
- `PAGE_TIMEOUT_MS` - page rendering timeout in milliseconds (default: 30000)
51+
4252
## CI/CD
4353

4454
Docker images are automatically built and pushed to DockerHub (`maximiliana/lucientes`) on pushes to master via `.github/workflows/docker-publish.yml`.

Dockerfile

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
FROM node:20-slim AS builder
1+
FROM node:22-slim AS builder
22
WORKDIR /usr/src/app
33
ENV PUPPETEER_SKIP_CHROME_DOWNLOAD=true
44
COPY ./web-service/package*.json ./
55
RUN npm ci --only=production
66
COPY ./web-service .
77

88

9-
FROM node:20-slim
9+
FROM node:22-slim
1010
WORKDIR /usr/src/app
11-
RUN apt-get update && apt-get install chromium -y --no-install-recommends
11+
12+
RUN apt-get update && apt-get install chromium -y --no-install-recommends \
13+
&& rm -rf /var/lib/apt/lists/* \
14+
&& groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
15+
&& mkdir -p /home/pptruser/Downloads \
16+
&& chown -R pptruser:pptruser /home/pptruser \
17+
&& chown -R pptruser:pptruser /usr/src/app
18+
1219
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
1320
EXPOSE 3000
14-
COPY --from=builder /usr/src/app .
21+
COPY --from=builder --chown=pptruser:pptruser /usr/src/app .
22+
23+
USER pptruser
1524
CMD ["npm", "start"]

README.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ Lucientes is developed by the Spanish startup Maximiliana to support the "Atenta
1717
- Simple and intuitive API.
1818
- Built with Node.js, Express, and Puppeteer.
1919

20-
## 🚀 Getting Started
20+
## ⚠️ Security warning
21+
22+
This service renders arbitrary HTML in a headless browser. **This is inherently risky.**
23+
Lucientes includes some security layers to mitigate these risks, but no defense is perfect. **Use with caution and always require API key authentication in production.**
24+
25+
## 🚀 Getting started
2126

2227
### Prerequisites
2328

@@ -33,14 +38,21 @@ Make sure you have Docker installed on your system.
3338

3439
2. **Run the Docker container**:
3540
```sh
36-
docker run -p 8080:3000 --env PORT=3000 maximiliana/lucientes
41+
docker run -p 8080:3000 maximiliana/lucientes
42+
```
43+
44+
3. **Run with API Key protection (recommended for production)**:
45+
```sh
46+
docker run -p 8080:3000 \
47+
-e API_KEY=MY_SECRET_KEY \
48+
maximiliana/lucientes
3749
```
3850

39-
3. **Run with API Key protection**:
51+
4. **Run with JavaScript enabled**:
4052
```sh
4153
docker run -p 8080:3000 \
42-
-e PORT=3000 \
4354
-e API_KEY=MY_SECRET_KEY \
55+
-e ALLOW_JAVASCRIPT=true \
4456
maximiliana/lucientes
4557
```
4658

@@ -146,8 +158,29 @@ Parameters (multipart form fields):
146158
147159
### Environment Variables
148160

149-
- `PORT`: The port on which the server will run (default 3000).
150-
- `API_KEY`: If set, enables authentication; each request must include `x-api-key` header.
161+
| Variable | Default | Description |
162+
|----------|---------|-------------|
163+
| `PORT` | `3000` | Server port |
164+
| `API_KEY` | (none) | If set, requires `x-api-key` header on all requests |
165+
| `ALLOW_JAVASCRIPT` | `false` | Set to `true` to enable JavaScript execution in HTML |
166+
| `ALLOW_EXTERNAL_REQUESTS` | `false` | Set to `true` to allow network requests from rendered pages |
167+
| `MAX_DIMENSION` | `4096` | Maximum width/height in pixels |
168+
| `PAGE_TIMEOUT_MS` | `30000` | Page rendering timeout in milliseconds |
169+
170+
### Security measures
171+
172+
Lucientes implements several layers of security:
173+
174+
| Protection | Description |
175+
|------------|-------------|
176+
| **Non-root user** | The container runs as an unprivileged user (`pptruser`). |
177+
| **JavaScript disabled** | JS execution is off by default. Enable only if needed with `ALLOW_JAVASCRIPT=true`. |
178+
| **Network requests blocked** | External requests (fetch, images, iframes) are blocked by default. Enable with `ALLOW_EXTERNAL_REQUESTS=true`. |
179+
| **Dimension limits** | Prevents memory exhaustion from extremely large images. |
180+
| **Timeouts** | Prevents infinite loops and resource exhaustion. |
181+
| **Timing-safe API key comparison** | Prevents timing attacks on API key validation. |
182+
183+
> **Note:** Chrome sandbox is disabled (`--no-sandbox`) for compatibility with containerized environments like Cloud Run. The other security measures compensate for this.
151184
152185
## 🖼️ Trivia
153186

web-service/index.js

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import puppeteer from "puppeteer";
33
import multer from "multer";
44
import fs from "fs";
55
import path from "path";
6+
import crypto from "crypto";
67
import { fileURLToPath } from "url";
78
import { dirname } from "path";
89

@@ -12,6 +13,10 @@ const __dirname = dirname(__filename);
1213
const app = express();
1314
const PORT = process.env.PORT || 3000;
1415
const API_KEY = process.env.API_KEY;
16+
const ALLOW_JAVASCRIPT = process.env.ALLOW_JAVASCRIPT === "true";
17+
const ALLOW_EXTERNAL_REQUESTS = process.env.ALLOW_EXTERNAL_REQUESTS === "true";
18+
const MAX_DIMENSION = parseInt(process.env.MAX_DIMENSION, 10) || 4096;
19+
const PAGE_TIMEOUT_MS = parseInt(process.env.PAGE_TIMEOUT_MS, 10) || 30000;
1520

1621
const log = (severity, message, data = {}) => {
1722
const entry = {
@@ -26,6 +31,15 @@ const log = (severity, message, data = {}) => {
2631
}
2732
};
2833

34+
const secureCompare = (a, b) => {
35+
if (!a || !b) return false;
36+
try {
37+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
38+
} catch {
39+
return false;
40+
}
41+
};
42+
2943
const upload = multer({ dest: "uploads/" });
3044

3145
app.use(express.json());
@@ -34,7 +48,7 @@ app.use(express.urlencoded({ extended: true }));
3448
app.use((req, res, next) => {
3549
if (!API_KEY) return next();
3650
const provided = req.header("x-api-key");
37-
if (provided !== API_KEY) {
51+
if (!secureCompare(provided, API_KEY)) {
3852
log("WARNING", "Unauthorized request (invalid or missing API Key)", {
3953
path: req.path,
4054
method: req.method,
@@ -53,6 +67,45 @@ const validFormats = ["png", "jpeg", "webp"];
5367
// https://github.com/puppeteer/puppeteer/issues/3357
5468
const PDF_DPI_CORRECTION_FACTOR = 96 / 72;
5569

70+
const BROWSER_ARGS = [
71+
"--no-sandbox",
72+
"--disable-setuid-sandbox",
73+
"--disable-dev-shm-usage",
74+
"--disable-accelerated-2d-canvas",
75+
"--disable-gpu",
76+
"--disable-background-networking",
77+
"--disable-default-apps",
78+
"--disable-extensions",
79+
"--disable-sync",
80+
"--disable-translate",
81+
"--hide-scrollbars",
82+
"--metrics-recording-only",
83+
"--mute-audio",
84+
"--no-first-run",
85+
"--safebrowsing-disable-auto-update",
86+
];
87+
88+
const setupPageSecurity = async (page) => {
89+
page.setDefaultTimeout(PAGE_TIMEOUT_MS);
90+
91+
if (!ALLOW_JAVASCRIPT) {
92+
await page.setJavaScriptEnabled(false);
93+
}
94+
95+
if (!ALLOW_EXTERNAL_REQUESTS) {
96+
await page.setRequestInterception(true);
97+
page.on("request", (request) => {
98+
const url = request.url();
99+
if (url.startsWith("data:") || url === "about:blank") {
100+
request.continue();
101+
} else {
102+
log("WARNING", "Blocked external request", { url });
103+
request.abort("blockedbyclient");
104+
}
105+
});
106+
}
107+
};
108+
56109
const parseBoolean = (value) => {
57110
if (value === undefined) return false;
58111
if (typeof value === "boolean") return value;
@@ -74,6 +127,12 @@ const validateRequest = (req) => {
74127
if (height && (isNaN(height) || parseInt(height, 10) <= 0)) {
75128
return { isValid: false, message: "Height must be a positive number" };
76129
}
130+
if (width && parseInt(width, 10) > MAX_DIMENSION) {
131+
return { isValid: false, message: `Width exceeds maximum of ${MAX_DIMENSION}px` };
132+
}
133+
if (height && parseInt(height, 10) > MAX_DIMENSION) {
134+
return { isValid: false, message: `Height exceeds maximum of ${MAX_DIMENSION}px` };
135+
}
77136
if (scaleFactor && (isNaN(scaleFactor) || parseFloat(scaleFactor) <= 0)) {
78137
return {
79138
isValid: false,
@@ -110,6 +169,12 @@ const validatePdfRequest = (req) => {
110169
if (height && (isNaN(height) || parseInt(height, 10) <= 0)) {
111170
return { isValid: false, message: "Height must be a positive number" };
112171
}
172+
if (width && parseInt(width, 10) > MAX_DIMENSION) {
173+
return { isValid: false, message: `Width exceeds maximum of ${MAX_DIMENSION}px` };
174+
}
175+
if (height && parseInt(height, 10) > MAX_DIMENSION) {
176+
return { isValid: false, message: `Height exceeds maximum of ${MAX_DIMENSION}px` };
177+
}
113178
return { isValid: true };
114179
};
115180

@@ -131,10 +196,9 @@ app.post("/html-to-image", upload.single("file"), async (req, res) => {
131196
const transparentRequested = validation.transparentRequested;
132197

133198
try {
134-
const browser = await puppeteer.launch({
135-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
136-
});
199+
const browser = await puppeteer.launch({ args: BROWSER_ARGS });
137200
const page = await browser.newPage();
201+
await setupPageSecurity(page);
138202

139203
const viewportWidth = width ? parseInt(width, 10) : 1920;
140204
const viewportHeight = height ? parseInt(height, 10) : 1080;
@@ -147,7 +211,9 @@ app.post("/html-to-image", upload.single("file"), async (req, res) => {
147211
});
148212

149213
const htmlContent = fs.readFileSync(htmlFilePath, "utf8");
150-
await page.setContent(htmlContent);
214+
await page.setContent(htmlContent, {
215+
waitUntil: ALLOW_JAVASCRIPT ? "load" : "domcontentloaded"
216+
});
151217

152218
const screenshot = await page.screenshot({
153219
type: imageFormat,
@@ -157,7 +223,6 @@ app.post("/html-to-image", upload.single("file"), async (req, res) => {
157223
});
158224

159225
await browser.close();
160-
fs.unlinkSync(htmlFilePath);
161226

162227
log("INFO", "Image generated successfully", {
163228
width: viewportWidth,
@@ -178,6 +243,8 @@ app.post("/html-to-image", upload.single("file"), async (req, res) => {
178243
ip: req.ip,
179244
});
180245
res.status(500).send("Internal Server Error");
246+
} finally {
247+
fs.unlink(htmlFilePath, () => {});
181248
}
182249
});
183250

@@ -202,18 +269,19 @@ app.post("/html-to-pdf", upload.single("file"), async (req, res) => {
202269
const printBg = printBackground === undefined ? true : parseBoolean(printBackground);
203270

204271
try {
205-
const browser = await puppeteer.launch({
206-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
207-
});
272+
const browser = await puppeteer.launch({ args: BROWSER_ARGS });
208273
const page = await browser.newPage();
274+
await setupPageSecurity(page);
209275

210276
await page.setViewport({
211277
width: viewportWidth,
212278
height: viewportHeight,
213279
});
214280

215281
const htmlContent = fs.readFileSync(htmlFilePath, "utf8");
216-
await page.setContent(htmlContent);
282+
await page.setContent(htmlContent, {
283+
waitUntil: ALLOW_JAVASCRIPT ? "load" : "domcontentloaded"
284+
});
217285

218286
const pdfWidth = Math.round(viewportWidth * PDF_DPI_CORRECTION_FACTOR);
219287
const pdfHeight = Math.round(viewportHeight * PDF_DPI_CORRECTION_FACTOR);
@@ -226,7 +294,6 @@ app.post("/html-to-pdf", upload.single("file"), async (req, res) => {
226294
});
227295

228296
await browser.close();
229-
fs.unlinkSync(htmlFilePath);
230297

231298
log("INFO", "PDF generated successfully", {
232299
width: viewportWidth,
@@ -246,15 +313,24 @@ app.post("/html-to-pdf", upload.single("file"), async (req, res) => {
246313
ip: req.ip,
247314
});
248315
res.status(500).send("Internal Server Error");
316+
} finally {
317+
fs.unlink(htmlFilePath, () => {});
249318
}
250319
});
251320

252321
app.listen(PORT, "0.0.0.0", () => {
253322
log("INFO", "Server started", {
254323
url: `http://0.0.0.0:${PORT}`,
255324
apiKeyProtection: Boolean(API_KEY),
325+
javascriptEnabled: ALLOW_JAVASCRIPT,
326+
externalRequestsEnabled: ALLOW_EXTERNAL_REQUESTS,
327+
maxDimension: MAX_DIMENSION,
328+
pageTimeoutMs: PAGE_TIMEOUT_MS,
256329
});
257-
if (API_KEY) {
258-
log("NOTICE", "API Key protection enabled (header only)");
330+
if (!ALLOW_JAVASCRIPT) {
331+
log("NOTICE", "JavaScript execution is DISABLED (set ALLOW_JAVASCRIPT=true to enable)");
332+
}
333+
if (!ALLOW_EXTERNAL_REQUESTS) {
334+
log("NOTICE", "External requests are BLOCKED (set ALLOW_EXTERNAL_REQUESTS=true to enable)");
259335
}
260336
});

0 commit comments

Comments
 (0)