diff --git a/server/package-lock.json b/server/package-lock.json index cbb1f31..25e846b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -21,7 +21,8 @@ "sharp": "^0.33.0" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "supertest": "^7.2.2" } }, "node_modules/@emnapi/runtime": { @@ -652,6 +653,29 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pdf-lib/standard-fonts": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", @@ -715,6 +739,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -904,6 +942,29 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -962,6 +1023,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -994,6 +1062,16 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1022,6 +1100,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1093,6 +1182,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1169,6 +1274,13 @@ "express": ">= 4.11" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1200,6 +1312,41 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1326,6 +1473,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1700,6 +1863,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -2121,6 +2294,90 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2231,6 +2488,13 @@ "node": ">= 0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2241,4 +2505,4 @@ } } } -} +} \ No newline at end of file diff --git a/server/package.json b/server/package.json index d95cd3c..b589f8f 100644 --- a/server/package.json +++ b/server/package.json @@ -18,18 +18,19 @@ "author": "", "license": "MIT", "dependencies": { - "express": "^4.18.2", "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", "multer": "^1.4.5-lts.1", - "pdfjs-dist": "^5.5.207", "pdf-lib": "^1.17.1", + "pdfjs-dist": "^5.5.207", "qpdf-wasm-esm-embedded": "^1.1.1", - "sharp": "^0.33.0", - "dotenv": "^16.3.1", - "helmet": "^7.1.0", - "express-rate-limit": "^7.1.5" + "sharp": "^0.33.0" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "supertest": "^7.2.2" } -} +} \ No newline at end of file diff --git a/server/routes/image.test.js b/server/routes/image.test.js new file mode 100644 index 0000000..78c037a --- /dev/null +++ b/server/routes/image.test.js @@ -0,0 +1,316 @@ +/** + * Tests for server/routes/image.js + * + * Focused on the /html-to-image endpoint, which was modified in this PR to: + * - Remove the isInternalIP() SSRF protection function + * - Remove DNS resolution / DNS-rebinding protection + * - Fetch the URL directly using parsed.toString() + * + * Uses in-process request/response mocking (no TCP) because the sandbox + * blocks loopback connections. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import { IncomingMessage, ServerResponse } from 'node:http'; +import { PassThrough, Readable } from 'node:stream'; + +// --------------------------------------------------------------------------- +// Build the app once at module level +// --------------------------------------------------------------------------- +const { default: imageRouter } = await import('./image.js'); +const app = express(); +app.use(express.json()); +app.use('/api/image', imageRouter); + +// --------------------------------------------------------------------------- +// In-process HTTP helper +// Invokes app.handle() with mock stream objects — no TCP required. +// --------------------------------------------------------------------------- +async function mockRequest(method, path, body = null) { + const bodyStr = body !== null ? JSON.stringify(body) : ''; + const bodyBuf = Buffer.from(bodyStr, 'utf8'); + + const req = Object.assign( + new Readable({ + read() { + if (bodyStr) this.push(bodyBuf); + this.push(null); + } + }), + { + method: method.toUpperCase(), + url: path, + headers: { + 'content-type': 'application/json', + 'content-length': String(bodyBuf.length) + } + } + ); + + const chunks = []; + const socket = new PassThrough(); + socket.on('data', chunk => chunks.push(chunk)); + + const res = new ServerResponse(req); + res.assignSocket(socket); + + await new Promise((resolve, reject) => { + res.on('finish', resolve); + res.on('error', reject); + app.handle(req, res); + }); + + const rawResponse = Buffer.concat(chunks).toString('utf8'); + // HTTP response format: headers\r\n\r\nbody + const headerBodySplit = rawResponse.indexOf('\r\n\r\n'); + const headerSection = rawResponse.slice(0, headerBodySplit); + const rawBody = rawResponse.slice(headerBodySplit + 4); + + // Parse headers into a plain object (lower-cased) + const headerLines = headerSection.split('\r\n').slice(1); // skip status line + const headers = {}; + for (const line of headerLines) { + const idx = line.indexOf(':'); + if (idx > -1) { + headers[line.slice(0, idx).toLowerCase().trim()] = line.slice(idx + 1).trim(); + } + } + + let parsedBody; + try { + parsedBody = JSON.parse(rawBody); + } catch { + parsedBody = null; + } + + return { + status: res.statusCode, + headers, + text: rawBody, + body: parsedBody + }; +} + +// --------------------------------------------------------------------------- +// Minimal HTML document returned by the mock fetch +// --------------------------------------------------------------------------- +const MOCK_HTML = '
Hello world
'; + +function makeFetchMock({ ok = true, status = 200, text = MOCK_HTML } = {}) { + return async () => ({ ok, status, text: async () => text }); +} + +// --------------------------------------------------------------------------- +// /html-to-image — URL validation tests (no fetch required) +// --------------------------------------------------------------------------- + +describe('/api/image/html-to-image – URL validation', () => { + it('returns 400 when url is missing from body', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image', { format: 'svg' }); + assert.equal(res.status, 400); + assert.match(res.body.error, /valid url is required/i); + }); + + it('returns 400 when url is not a string (number)', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 12345 }); + assert.equal(res.status, 400); + assert.match(res.body.error, /valid url is required/i); + }); + + it('returns 400 when url is not a string (null)', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image', { url: null }); + assert.equal(res.status, 400); + assert.match(res.body.error, /valid url is required/i); + }); + + it('returns 400 for body-less request', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image'); + assert.equal(res.status, 400); + assert.match(res.body.error, /valid url is required/i); + }); + + it('returns 400 when url is an unparseable string', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 'not a url at all :::' }); + assert.equal(res.status, 400); + assert.match(res.body.error, /invalid url format/i); + }); + + it('returns 400 when protocol is file://', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 'file:///etc/passwd' }); + assert.equal(res.status, 400); + assert.match(res.body.error, /only http\/https/i); + }); + + it('returns 400 when protocol is ftp://', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 'ftp://example.com/resource' }); + assert.equal(res.status, 400); + assert.match(res.body.error, /only http\/https/i); + }); + + it('returns 400 for javascript: pseudo-protocol', async () => { + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 'javascript:alert(1)' }); + assert.equal(res.status, 400); + }); +}); + +// --------------------------------------------------------------------------- +// /html-to-image — fetch behaviour tests (fetch is mocked) +// --------------------------------------------------------------------------- + +describe('/api/image/html-to-image – fetch behaviour', () => { + let originalFetch; + + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns 502 when the remote server responds with a non-OK status', async () => { + globalThis.fetch = makeFetchMock({ ok: false, status: 404 }); + + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 'http://example.com/not-found' }); + + assert.equal(res.status, 502); + assert.match(res.body.error, /unable to fetch url/i); + assert.match(res.body.error, /404/); + }); + + it('returns 500 when fetch throws a network error', async () => { + globalThis.fetch = async () => { throw new Error('Network failure'); }; + + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 'http://example.com' }); + + assert.equal(res.status, 500); + assert.match(res.body.error, /failed to convert/i); + }); + + it('returns SVG content-type when format is "svg"', async () => { + globalThis.fetch = makeFetchMock(); + + const res = await mockRequest('POST', '/api/image/html-to-image', { url: 'http://example.com', format: 'svg' }); + + assert.equal(res.status, 200); + assert.match(res.headers['content-type'], /image\/svg\+xml/); + assert.match(res.headers['content-disposition'], /webpage_capture\.svg/); + assert.ok(res.text.includes('