From 6d5fad2aa9ca17ff20031f718f6147a221221f0b Mon Sep 17 00:00:00 2001 From: Max Dietrich Date: Sat, 21 Feb 2026 22:04:31 +0100 Subject: [PATCH 1/3] Comprehensive security hardening for self-hosted deployments - SSRF prevention: URL validation (IPv4/IPv6/mapped/ULA/link-local/decimal/hex), redirect blocking on tile and marker fetches, tileLayers and subdomain validation - SVG injection prevention: escapeXml on all user-interpolated SVG attributes including marker colors, text, attribution - Auth hardening: HMAC-signed demo cookies, timing-safe API key comparison - Resource limits: pixel budget (25MP), Sharp limitInputPixels, cache maxKeys, feature count cap (1000), zoom/timeout/tileRequestLimit caps, format allowlist - Security headers: CSP without unsafe-inline (extracted styles to demo.css), strict Referrer-Policy, HSTS, X-Frame-Options, Permissions-Policy - Log safety: API key redaction in request logs and error handler - Rate limiting on demo-map proxy, cookie path scoping - Tile header sanitization via allowlist - GitHub Actions bumped to latest major versions (checkout@v4, setup-node@v4, build-push-action@v6) --- .github/workflows/build-test.yml | 4 +- .github/workflows/docker-build.yml | 8 +- .gitignore | 5 + __tests__/middlewares/authConfig.test.ts | 14 ++- __tests__/middlewares/headers.test.ts | 2 +- __tests__/staticmaps/renderer.markers.test.ts | 3 +- public/demo.css | 90 ++++++++++++++ public/index.html | 97 +-------------- src/generate/generateParams.ts | 85 ++++++++++---- src/generate/parseTileConfig.ts | 10 +- src/middlewares/apiKeyAuth.ts | 8 +- src/middlewares/authConfig.ts | 43 ++++++- src/middlewares/headers.ts | 6 +- src/server.ts | 65 +++++----- src/staticmaps/image.ts | 2 +- src/staticmaps/renderer.ts | 46 ++++++-- src/staticmaps/tilemanager.ts | 32 +++-- src/utils/attribution.ts | 3 +- src/utils/cache.ts | 2 +- src/utils/security.ts | 111 ++++++++++++++++++ 20 files changed, 441 insertions(+), 195 deletions(-) create mode 100644 public/demo.css create mode 100644 src/utils/security.ts diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 780aab3..5d6e885 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: "22" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 3a4b656..adef775 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -18,8 +18,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: "22" - run: npm install @@ -52,7 +52,7 @@ jobs: - name: Build and push image with branch/commit tags (push events) if: github.event_name == 'push' - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile @@ -68,7 +68,7 @@ jobs: - name: Build and push image with release tag (release events only) if: github.event_name == 'release' - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile diff --git a/.gitignore b/.gitignore index 96b2a2d..b30df4a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# environment / secrets +.env +.env.local +.env.*.local + # build dist *tar.gz diff --git a/__tests__/middlewares/authConfig.test.ts b/__tests__/middlewares/authConfig.test.ts index 1721a11..d76fed7 100644 --- a/__tests__/middlewares/authConfig.test.ts +++ b/__tests__/middlewares/authConfig.test.ts @@ -96,8 +96,9 @@ describe("AuthConfig", () => { next = jest.fn() }) - test("calls next() if demo_auth cookie is true", () => { - req.headers!.cookie = "demo_auth=true" + test("calls next() if demo_auth cookie is valid signed value", () => { + const signed = AuthConfig.signDemoCookie() + req.headers!.cookie = `demo_auth=${signed}` AuthConfig.checkDemoCookie(req as Request, res as Response, next) expect(next).toHaveBeenCalled() expect(res.status).not.toHaveBeenCalled() @@ -110,16 +111,17 @@ describe("AuthConfig", () => { expect(next).not.toHaveBeenCalled() }) - test("returns 401 if demo_auth cookie is not true", () => { - req.headers!.cookie = "demo_auth=false" + test("returns 401 if demo_auth cookie is invalid", () => { + req.headers!.cookie = "demo_auth=forged_value" AuthConfig.checkDemoCookie(req as Request, res as Response, next) expect(res.status).toHaveBeenCalledWith(401) expect(res.send).toHaveBeenCalledWith("Unauthorized") expect(next).not.toHaveBeenCalled() }) - test("handles multiple cookies and still calls next() when demo_auth=true", () => { - req.headers!.cookie = "other=123; demo_auth=true; another=456" + test("handles multiple cookies and still calls next() when demo_auth is valid", () => { + const signed = AuthConfig.signDemoCookie() + req.headers!.cookie = `other=123; demo_auth=${signed}; another=456` AuthConfig.checkDemoCookie(req as Request, res as Response, next) expect(next).toHaveBeenCalled() expect(res.status).not.toHaveBeenCalled() diff --git a/__tests__/middlewares/headers.test.ts b/__tests__/middlewares/headers.test.ts index 1c445d3..52e0c60 100644 --- a/__tests__/middlewares/headers.test.ts +++ b/__tests__/middlewares/headers.test.ts @@ -34,7 +34,7 @@ describe("headers middleware", () => { ) expect(res.setHeader).toHaveBeenCalledWith( "Referrer-Policy", - "no-referrer-when-downgrade" + "strict-origin-when-cross-origin" ) expect(res.setHeader).toHaveBeenCalledWith( "Permissions-Policy", diff --git a/__tests__/staticmaps/renderer.markers.test.ts b/__tests__/staticmaps/renderer.markers.test.ts index 7132358..a8be18f 100644 --- a/__tests__/staticmaps/renderer.markers.test.ts +++ b/__tests__/staticmaps/renderer.markers.test.ts @@ -209,6 +209,7 @@ describe("loadMarkers", () => { const arrayBuffer = new Uint8Array([1, 2, 3]).buffer ;(global.fetch as jest.Mock).mockResolvedValue({ ok: true, + headers: { get: (name: string) => name === "content-type" ? "image/png" : null }, arrayBuffer: jest.fn().mockResolvedValue(arrayBuffer), }) @@ -220,7 +221,7 @@ describe("loadMarkers", () => { expect(global.fetch).toHaveBeenCalledWith( "https://example.com/marker.png", - { method: "GET" } + { method: "GET", redirect: "manual" } ) expect(sharpMock).toHaveBeenCalled() expect(sharpInstance.resize).toHaveBeenCalledWith(10, 15) diff --git a/public/demo.css b/public/demo.css new file mode 100644 index 0000000..4b30bb8 --- /dev/null +++ b/public/demo.css @@ -0,0 +1,90 @@ +body { + font-family: system-ui, sans-serif; + margin: 0; + padding: 1.5rem; + background: #f8fafc; + color: #1e293b; + max-width: 1200px; + margin: 0 auto; + display: grid; + gap: 1rem; +} + +p { + margin: 0; + font-size: 0.9rem; +} + +h1 { + margin: 0; + font-size: 1.5rem; +} + +#map { + width: 100%; + height: 400px; + border-radius: 0.75rem; + overflow: hidden; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.controls { + margin-top: 1rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +button { + padding: 0.6rem 1.2rem; + border: none; + border-radius: 0.5rem; + background: #3b82f6; + color: #fff; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.2s; +} + +button:hover { + background: #2563eb; +} + +#basemapSelect { + padding: 0.6rem 1.2rem; + border: none; + border-radius: 0.5rem; + background: #3b82f6; + color: #fff; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.2s; + font-family: system-ui, sans-serif; +} + +#basemapSelect:hover, +#basemapSelect:focus { + background: #2563eb; + outline: none; +} + +#basemapSelect option { + color: #fff; + background: #3b82f6; + font-size: 0.95rem; +} + +#staticMapUrlDisplay { + font-family: monospace; + color: #0f172a; + word-break: break-all; +} + +img.static-map { + margin-top: 1rem; + width: 100%; + max-height: 400px; + border-radius: 0.75rem; + object-fit: cover; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} diff --git a/public/index.html b/public/index.html index 29b9f63..4307af9 100644 --- a/public/index.html +++ b/public/index.html @@ -5,99 +5,7 @@ docker-staticmaps demo - +

github.com/dietrichmax/docker-staticmaps demo

@@ -114,8 +22,7 @@

github.com/dietrichmax/docker-staticmaps demo

Current Static Map URL: - /staticmaps?/staticmaps?

diff --git a/src/generate/generateParams.ts b/src/generate/generateParams.ts index c814fa7..16789b1 100644 --- a/src/generate/generateParams.ts +++ b/src/generate/generateParams.ts @@ -9,6 +9,7 @@ import logger from "../utils/logger" import { parseCenter } from "./parseCoordinates" import { parseMultipleShapes } from "./parseShapes" import { getTileUrl, parseAttributionParam, parseBorderParam } from "./parseTileConfig" +import { sanitizeTileHeaders, isPrivateUrl } from "../utils/security" // Re-export submodule functions for backward compatibility with tests export { isEncodedPolyline, parseCoordinates, parseCenter } from "./parseCoordinates" @@ -56,10 +57,18 @@ const DEFAULTS = { const LIMITS = { MAX_WIDTH: 8192, MAX_HEIGHT: 8192, + MAX_PIXELS: 25_000_000, MIN_WIDTH: 1, MIN_HEIGHT: 1, } +const ALLOWED_FORMATS = new Set(["png", "jpeg", "jpg", "webp", "pdf"]) + +const MAX_TILE_REQUEST_LIMIT = 8 +const MAX_TILE_REQUEST_TIMEOUT = 30_000 +const MAX_ZOOM = 20 +const MAX_FEATURES = 1000 + /** * Default style and properties for each supported shape type. */ @@ -126,8 +135,16 @@ export function getMapParams(params: MapParamsInput): MapParamsOutput { logger.debug(`Parsed ${key}:`, features[key]) } + const totalFeatures = Object.values(features).reduce( + (sum, list) => sum + (Array.isArray(list) ? list.length : list ? 1 : 0), + 0 + ) + if (totalFeatures > MAX_FEATURES) { + throw new Error(`Too many features: ${totalFeatures} exceeds limit of ${MAX_FEATURES}`) + } + const center = parseCenter(params.center) - const quality = parseInt(params.quality || "100", 10) + const quality = Math.max(1, Math.min(100, parseInt(params.quality || "100", 10))) const width = parseInt((params.width ?? DEFAULTS.width).toString(), 10) const height = parseInt((params.height ?? DEFAULTS.height).toString(), 10) @@ -165,26 +182,35 @@ export function getMapParams(params: MapParamsInput): MapParamsOutput { ...(params.paddingX && { paddingX: parseInt(params.paddingX, 10) }), ...(params.paddingY && { paddingY: parseInt(params.paddingY, 10) }), ...(params.tileSize && { tileSize: parseInt(params.tileSize, 10) }), - ...(params.zoom && { zoom: parseInt(params.zoom, 10) }), - ...(params.format && { format: params.format }), + ...(params.zoom && { zoom: Math.min(parseInt(params.zoom, 10), MAX_ZOOM) }), + ...(params.format && { + format: ALLOWED_FORMATS.has(params.format.toLowerCase()) + ? params.format + : (() => { throw new Error(`Unsupported format: "${params.format}". Allowed: ${[...ALLOWED_FORMATS].join(", ")}`) })(), + }), ...(params.tileRequestTimeout && { - tileRequestTimeout: params.tileRequestTimeout, + tileRequestTimeout: Math.min(Math.max(0, Number(params.tileRequestTimeout)), MAX_TILE_REQUEST_TIMEOUT), }), ...(params.tileRequestHeader && { - tileRequestHeader: params.tileRequestHeader, + tileRequestHeader: sanitizeTileHeaders(params.tileRequestHeader), }), ...(params.tileRequestLimit && { - tileRequestLimit: params.tileRequestLimit, + tileRequestLimit: Math.min(Number(params.tileRequestLimit), MAX_TILE_REQUEST_LIMIT), + }), + ...(params.zoomRange && { + zoomRange: { + min: Math.max(1, Math.min(Number(params.zoomRange.min) || 1, MAX_ZOOM)), + max: Math.max(1, Math.min(Number(params.zoomRange.max) || 17, MAX_ZOOM)), + }, }), - ...(params.zoomRange && { zoomRange: params.zoomRange }), ...(typeof params.reverseY !== "undefined" && { reverseY: params.reverseY, }), ...(typeof params.tileSubdomains !== "undefined" && { - tileSubdomains: params.tileSubdomains, + tileSubdomains: validateSubdomains(params.tileSubdomains), }), ...(typeof params.tileLayers !== "undefined" && { - tileLayers: params.tileLayers, + tileLayers: validateTileLayers(params.tileLayers), }), ...(typeof attribution?.show !== "undefined" || attribution?.text ? { attribution } @@ -205,28 +231,43 @@ export function getMapParams(params: MapParamsInput): MapParamsOutput { } } -/** - * Validates the requested image width and height against predefined limits. - * - * @param {number} width - The requested image width in pixels. - * @param {number} height - The requested image height in pixels. - * @throws {Error} Throws an error if the width or height is out of allowed bounds. - */ +function validateSubdomains(subdomains: any): string[] { + if (!Array.isArray(subdomains)) return [] + return subdomains + .filter((s: any) => typeof s === "string" && /^[a-zA-Z0-9-]+$/.test(s)) + .slice(0, 10) +} + +function validateTileLayers(layers: any): any[] { + if (!Array.isArray(layers)) return [] + return layers.slice(0, 10).map((layer: any) => { + if (!layer || typeof layer !== "object") return {} + const testUrl = (layer.tileUrl || "").replace(/\{[^}]+\}/g, "0") + if (layer.tileUrl && isPrivateUrl(testUrl)) { + logger.error(`Blocked private/internal tile layer URL: ${layer.tileUrl}`) + return { ...layer, tileUrl: "" } + } + return { + ...layer, + ...(layer.tileSubdomains && { tileSubdomains: validateSubdomains(layer.tileSubdomains) }), + ...(layer.subdomains && { subdomains: validateSubdomains(layer.subdomains) }), + } + }) +} + function validateDimensions(width: number, height: number) { if (width > LIMITS.MAX_WIDTH || height > LIMITS.MAX_HEIGHT) { - logger.error( + throw new Error( `Requested image size ${width}x${height} exceeds maximum allowed ` + `${LIMITS.MAX_WIDTH}x${LIMITS.MAX_HEIGHT}.` ) + } + if (width * height > LIMITS.MAX_PIXELS) { throw new Error( - `Requested image size ${width}x${height} exceeds maximum allowed ` + - `${LIMITS.MAX_WIDTH}x${LIMITS.MAX_HEIGHT}.` + `Requested image size ${width}x${height} (${(width * height).toLocaleString()} pixels) exceeds pixel budget of ${LIMITS.MAX_PIXELS.toLocaleString()}.` ) } if (width < LIMITS.MIN_WIDTH || height < LIMITS.MIN_HEIGHT) { - logger.error( - `Image dimensions must be at least ${LIMITS.MIN_WIDTH}x${LIMITS.MIN_HEIGHT}.` - ) throw new Error( `Image dimensions must be at least ${LIMITS.MIN_WIDTH}x${LIMITS.MIN_HEIGHT}.` ) diff --git a/src/generate/parseTileConfig.ts b/src/generate/parseTileConfig.ts index 91336a7..88ca08a 100644 --- a/src/generate/parseTileConfig.ts +++ b/src/generate/parseTileConfig.ts @@ -1,5 +1,6 @@ import { basemaps } from "../utils/basemaps" import logger from "../utils/logger" +import { isPrivateUrl } from "../utils/security" /** * Generates a tile URL and attribution based on the provided custom URL and basemap. @@ -12,7 +13,14 @@ export function getTileUrl( customUrl: string | null, basemapName: string | null ): { url: string; attribution: string } { - if (customUrl) return { url: customUrl, attribution: "" } + if (customUrl) { + const testUrl = customUrl.replace(/\{[^}]+\}/g, "0") + if (isPrivateUrl(testUrl)) { + logger.error(`Blocked private/internal tile URL: ${customUrl}`) + return { url: "", attribution: "" } + } + return { url: customUrl, attribution: "" } + } const name = basemapName || "osm" const tile = basemaps.find((b) => b.basemap === name) if (!tile) { diff --git a/src/middlewares/apiKeyAuth.ts b/src/middlewares/apiKeyAuth.ts index 941293d..6473926 100644 --- a/src/middlewares/apiKeyAuth.ts +++ b/src/middlewares/apiKeyAuth.ts @@ -4,6 +4,7 @@ */ import { Request, Response, NextFunction } from "express" +import { timingSafeEqual } from "crypto" import logger from "../utils/logger" import AuthConfig from "./authConfig" @@ -27,7 +28,12 @@ export function authenticateApiKey( if (!AuthConfig.requireAuth) return next() const key = AuthConfig.extractApiKey(req) - if (key === AuthConfig.apiKey) return next() + if ( + key && + AuthConfig.apiKey && + key.length === AuthConfig.apiKey.length && + timingSafeEqual(Buffer.from(key), Buffer.from(AuthConfig.apiKey)) + ) return next() logger.warn(`Unauthorized access from IP=${req.ip}, API key=[REDACTED]`) res.status(403).json({ error: "Forbidden: Invalid or missing API key" }) diff --git a/src/middlewares/authConfig.ts b/src/middlewares/authConfig.ts index eb69b35..e34dc5e 100644 --- a/src/middlewares/authConfig.ts +++ b/src/middlewares/authConfig.ts @@ -4,6 +4,7 @@ */ import { Request, Response, NextFunction } from "express" +import crypto from "crypto" import logger from "../utils/logger" /** @@ -17,6 +18,10 @@ class AuthConfig { /** Whether API key authentication is required */ static requireAuth: boolean = false + /** Secret for HMAC-signing demo cookies. Random per process if not set. */ + private static demoCookieSecret: string = + process.env.DEMO_COOKIE_SECRET || crypto.randomBytes(32).toString("hex") + /** * Initialize the auth configuration. * Logs whether API key authentication is enabled. @@ -53,9 +58,38 @@ class AuthConfig { ) } + /** Creates an HMAC-signed demo cookie value with a 30-minute expiry. */ + static signDemoCookie(): string { + const expires = Date.now() + 30 * 60 * 1000 + const payload = `demo:${expires}` + const sig = crypto + .createHmac("sha256", this.demoCookieSecret) + .update(payload) + .digest("hex") + return `${payload}.${sig}` + } + + /** Verifies an HMAC-signed demo cookie value. Returns true if valid and not expired. */ + static verifyDemoCookie(value: string): boolean { + const lastDot = value.lastIndexOf(".") + if (lastDot === -1) return false + const payload = value.slice(0, lastDot) + const sig = value.slice(lastDot + 1) + const expected = crypto + .createHmac("sha256", this.demoCookieSecret) + .update(payload) + .digest("hex") + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) + return false + const parts = payload.split(":") + if (parts.length !== 2 || parts[0] !== "demo") return false + const expires = parseInt(parts[1], 10) + return Date.now() < expires + } + /** * Middleware to check the demo cookie for /demo-map and similar endpoints. - * Only allows access if the browser has a valid `demo_auth` cookie. + * Only allows access if the browser has a valid HMAC-signed `demo_auth` cookie. */ static checkDemoCookie( req: Request, @@ -70,12 +104,13 @@ class AuthConfig { const cookies = Object.fromEntries( cookieHeader.split(";").map((c) => { - const [k, v] = c.trim().split("=") - return [k, v] + const idx = c.indexOf("=") + if (idx === -1) return [c.trim(), ""] + return [c.slice(0, idx).trim(), c.slice(idx + 1).trim()] }) ) - if (cookies.demo_auth === "true") { + if (cookies.demo_auth && AuthConfig.verifyDemoCookie(cookies.demo_auth)) { next() return } diff --git a/src/middlewares/headers.ts b/src/middlewares/headers.ts index 638df8d..3296148 100644 --- a/src/middlewares/headers.ts +++ b/src/middlewares/headers.ts @@ -28,7 +28,7 @@ export function headers(req: Request, res: Response, next: NextFunction): void { "X-Content-Type-Options": "nosniff", // Control referrer information - "Referrer-Policy": "no-referrer-when-downgrade", + "Referrer-Policy": "strict-origin-when-cross-origin", // Restrict browser feature access "Permissions-Policy": @@ -44,8 +44,8 @@ export function headers(req: Request, res: Response, next: NextFunction): void { "Content-Security-Policy": [ "default-src 'self';", "img-src 'self' data: blob: *;", - "style-src 'self' 'unsafe-inline';", - "script-src 'self' 'unsafe-inline';", + "style-src 'self';", + "script-src 'self';", "connect-src 'self';", "font-src 'self' data:;", "object-src 'none';", diff --git a/src/server.ts b/src/server.ts index 26fe863..68edbf9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,7 +24,9 @@ import logger from "./utils/logger" import { authenticateApiKey } from "./middlewares/apiKeyAuth" import { headers } from "./middlewares/headers" import { truncate, normalizeIp } from "./utils/helpers" +import { redactUrl } from "./utils/security" import AuthConfig from "./middlewares/authConfig" +import { rateLimiter } from "./utils/rateLimit" import fs from "fs" // Load environment variables from .env file @@ -44,9 +46,11 @@ const PORT = Number(process.env.PORT) || 3000 */ app.use((req: Request, _res: Response, next: NextFunction) => { const ip = req.ip ?? req.socket.remoteAddress ?? "unknown" + // Strip api_key from logged URL to avoid leaking secrets + const safeUrl = redactUrl(req.url) logger.info("Incoming request", { method: req.method, - url: truncate(req.url, 500), + url: truncate(safeUrl, 500), ip: normalizeIp(ip), }) next() @@ -94,56 +98,51 @@ app.use("/api", authenticateApiKey, routes) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +// Cache demo page HTML at startup +const demoHtml = fs.readFileSync( + path.join(__dirname, "..", "public", "index.html"), + "utf-8" +) + // ------------------- // DEMO PAGE ROUTE // ------------------- -/** - * Route handler for root `/` path. - * Sends the main index.html file from the parent 'public' folder. - * - * @param {Request} _req - HTTP request (not used). - * @param {Response} res - HTTP response. - */ app.get( ["/", "/index.html"], authenticateApiKey, (_req: Request, res: Response) => { - const htmlFile = path.join(__dirname, "..", "public", "index.html") - const html = fs.readFileSync(htmlFile, "utf-8") - - // Set HTTP-only cookie manually + const cookieValue = AuthConfig.signDemoCookie() const cookieOptions = [ - "demo_auth=true", + `demo_auth=${cookieValue}`, "HttpOnly", - "SameSite=Lax", - `Max-Age=${30 * 60}`, // 30 minutes + "SameSite=Strict", + "Path=/", + `Max-Age=${30 * 60}`, ] if (process.env.NODE_ENV === "production") { cookieOptions.push("Secure") } res.setHeader("Set-Cookie", cookieOptions.join("; ")) - res.send(html) + res.send(demoHtml) } ) -/** - * Route handler to proxy a demo static map request. - * Uses AuthConfig to check demo authentication cookie. - * - * @param {Request} req - HTTP request. - * @param {Response} res - HTTP response. - */ -app.get("/demo-map", AuthConfig.checkDemoCookie, async (req, res) => { +app.get("/demo-map", rateLimiter, AuthConfig.checkDemoCookie, async (req: Request, res: Response) => { try { const url = new URL("/api/staticmaps", `http://localhost:${PORT}`) - url.search = new URLSearchParams({ - ...req.query, - api_key: process.env.API_KEY!, - }).toString() - - const response = await fetch(url.toString()) + // Forward query params but use internal API key via header (not logged in URL) + const queryParams = { ...req.query } as Record + delete queryParams.api_key + delete queryParams.API_KEY + url.search = new URLSearchParams(queryParams).toString() + + const response = await fetch(url.toString(), { + headers: process.env.API_KEY + ? { "x-api-key": process.env.API_KEY } + : {}, + }) if (!response.ok) return res.status(response.status).send(await response.text()) @@ -200,15 +199,13 @@ app.get("/health", (_req, res) => { */ app.use( (err: Error, req: Request, res: Response, _next: NextFunction): void => { + const safeUrl = redactUrl(req.url) logger.error("Unhandled error occurred", { error: err.message, stack: err.stack, method: req.method, - url: req.url, + url: safeUrl, ip: req.ip, - headers: req.headers, - params: req.params, - body: req.method !== "GET" ? req.body : undefined, }) res.status(500).json({ error: "Internal server error" }) } diff --git a/src/staticmaps/image.ts b/src/staticmaps/image.ts index 9049eb3..c655b21 100644 --- a/src/staticmaps/image.ts +++ b/src/staticmaps/image.ts @@ -29,7 +29,7 @@ export default class Image { * @param data - Tile data including image buffer and box coordinates. */ async prepareTileParts(data: TileData): Promise { - const tile = sharp(data.body) + const tile = sharp(data.body, { limitInputPixels: 100_000_000 }) try { const metadata = await tile.metadata() if (!metadata.width || !metadata.height) { diff --git a/src/staticmaps/renderer.ts b/src/staticmaps/renderer.ts index 4afcde1..b0a39b7 100644 --- a/src/staticmaps/renderer.ts +++ b/src/staticmaps/renderer.ts @@ -11,6 +11,8 @@ import { } from "./utils" import { Text, Polyline, Circle, IconMarker } from "./features" import sharp from "sharp" +import { isSafeOutboundUrl, escapeXml } from "../utils/security" +import logger from "../utils/logger" /** * Draws a map tile layer by loading and rendering tiles based on the given viewport and configuration. @@ -217,8 +219,8 @@ export function lineToSVG({ ${text.text}` + text-anchor="${escapeXml(text.anchor)}" + >${escapeXml(text.text ?? "")}` } /** @@ -302,8 +304,8 @@ export function circleToSVG({ return `` } @@ -436,9 +438,31 @@ export async function loadMarkers( await Promise.all( icons.map(async (icon) => { if (isValidUrl(icon.file)) { - const response = await fetch(icon.file, { method: "GET" }) + if (!isSafeOutboundUrl(icon.file)) { + logger.warn(`Blocked private/internal marker URL: ${icon.file}`) + throw new Error(`Blocked private/internal marker URL`) + } + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10_000) + let response: globalThis.Response + try { + response = await fetch(icon.file, { method: "GET", redirect: "manual" as RequestRedirect, signal: controller.signal }) + } finally { + clearTimeout(timeoutId) + } + if (response.status >= 300 && response.status < 400) { + throw new Error(`Marker URL returned redirect, blocked for security`) + } if (!response.ok) throw new Error(`Failed to fetch image from ${icon.file}`) + const contentType = response.headers.get("content-type") + if (!contentType || !contentType.startsWith("image/")) { + throw new Error(`Marker URL did not return an image: ${icon.file}`) + } + const contentLength = Number(response.headers.get("content-length") || 0) + if (contentLength > 5 * 1024 * 1024) { + throw new Error(`Marker image too large: ${contentLength} bytes`) + } const arrayBuffer = await response.arrayBuffer() icon.data = await sharp(Buffer.from(arrayBuffer)) .resize(icon.width, icon.height) @@ -446,7 +470,7 @@ export async function loadMarkers( } else { const svgString = ` - + ` diff --git a/src/staticmaps/tilemanager.ts b/src/staticmaps/tilemanager.ts index 2c5c925..4f86cd0 100644 --- a/src/staticmaps/tilemanager.ts +++ b/src/staticmaps/tilemanager.ts @@ -67,15 +67,26 @@ export class TileManager { } } - const options = { - method: "GET", - headers: this.tileRequestHeader || {}, - timeout: this.tileRequestTimeout, + const headers = { ...this.tileRequestHeader } + if (process.env.TILE_USER_AGENT && !headers["User-Agent"]) { + headers["User-Agent"] = process.env.TILE_USER_AGENT } - try { - const res = await fetch(data.url, options) + const controller = new AbortController() + const timeout = Math.min(this.tileRequestTimeout ?? 10_000, 30_000) + const timeoutId = setTimeout(() => controller.abort(), timeout) + try { + const res = await fetch(data.url, { + method: "GET", + headers, + redirect: "manual" as RequestRedirect, + signal: controller.signal, + }) + + if (res.status >= 300 && res.status < 400) { + throw new Error(`Tile server returned redirect (${res.status}), blocked for security`) + } if (!res.ok) { throw new Error(`Failed to fetch tile: ${res.statusText}`) } @@ -83,10 +94,15 @@ export class TileManager { logger.debug(`Fetched tile: ${data.url}`) const contentType = res.headers.get("content-type") - if (contentType && !contentType.startsWith("image/")) { + if (!contentType || !contentType.startsWith("image/")) { throw new Error("Tiles server response with wrong data") } + const contentLength = Number(res.headers.get("content-length") || 0) + if (contentLength > 10 * 1024 * 1024) { + throw new Error(`Tile response too large: ${contentLength} bytes`) + } + const arrayBuffer = await res.arrayBuffer() const body = Buffer.from(arrayBuffer) @@ -105,6 +121,8 @@ export class TileManager { success: false, error: error.message || error, } + } finally { + clearTimeout(timeoutId) } } diff --git a/src/utils/attribution.ts b/src/utils/attribution.ts index 8d3c782..530d101 100644 --- a/src/utils/attribution.ts +++ b/src/utils/attribution.ts @@ -1,4 +1,5 @@ import { measureTextWidth } from "./helpers" +import { escapeXml } from "./security" /** * Creates an SVG buffer containing an attribution box with the specified text, @@ -50,7 +51,7 @@ export function createAttributionSVG( } - ${text} + ${escapeXml(text)} ` diff --git a/src/utils/cache.ts b/src/utils/cache.ts index d7a210e..e6a2280 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -16,7 +16,7 @@ const tileCacheTTL = parseInt(process.env.TILE_CACHE_TTL ?? "", 10) || 3600 * - stdTTL: standard TTL for cached items (in seconds). * - checkperiod: interval in seconds to check and purge expired cache entries. */ -const tileCache = new NodeCache({ stdTTL: tileCacheTTL, checkperiod: 120 }) +const tileCache = new NodeCache({ stdTTL: tileCacheTTL, checkperiod: 120, maxKeys: 500 }) /** * Retrieves a cached tile buffer by its cache key. diff --git a/src/utils/security.ts b/src/utils/security.ts new file mode 100644 index 0000000..f3c6cab --- /dev/null +++ b/src/utils/security.ts @@ -0,0 +1,111 @@ +import logger from "./logger" + +/** + * Checks if a URL points to a private/internal network address. + * Blocks: localhost, 127.x, ::1, 10.x, 172.16-31.x, 192.168.x, + * 169.254.x, 0.x, .local, .internal, and non-HTTP(S) schemes. + */ +export function isPrivateUrl(urlString: string): boolean { + try { + const url = new URL(urlString) + + if (url.protocol !== "http:" && url.protocol !== "https:") return true + + const hostname = url.hostname.replace(/^\[|\]$/g, "") + + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "::" || + hostname === "0.0.0.0" || + hostname.endsWith(".local") || + hostname.endsWith(".internal") + ) + return true + + // IPv6 private ranges + const lowerHost = hostname.toLowerCase() + if (lowerHost.startsWith("fc") || lowerHost.startsWith("fd")) return true // ULA fc00::/7 + if (lowerHost.startsWith("fe80")) return true // Link-local + if (lowerHost.startsWith("ff")) return true // Multicast + if (/^0{0,4}:{0,2}0{0,4}:{0,2}(0{0,4}:){0,3}[01]$/.test(lowerHost)) return true // ::1, ::, 0::1 + // IPv4-mapped IPv6: ::ffff:W.X.Y.Z + const v4mapped = lowerHost.match(/^:{0,2}ffff:(\d+\.\d+\.\d+\.\d+)$/) + if (v4mapped) return isPrivateIpv4(v4mapped[1]) + + // Quad-dotted IPv4 + const parts = hostname.split(".").map(Number) + if (parts.length === 4 && parts.every((n) => !isNaN(n))) { + return isPrivateIpv4(hostname) + } + + // Block decimal/hex IP encodings (e.g. 2130706433, 0x7f000001) + if (/^\d+$/.test(hostname) || /^0x[0-9a-fA-F]+$/.test(hostname)) return true + + return false + } catch { + return true + } +} + +function isPrivateIpv4(ip: string): boolean { + const parts = ip.split(".").map(Number) + if (parts.length !== 4 || parts.some((n) => isNaN(n))) return false + if (parts[0] === 10) return true + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true + if (parts[0] === 192 && parts[1] === 168) return true + if (parts[0] === 169 && parts[1] === 254) return true + if (parts[0] === 0) return true + if (parts[0] === 127) return true + return false +} + +/** Logs and rejects private/internal URLs before outbound fetches. */ +export function isSafeOutboundUrl(urlString: string): boolean { + if (isPrivateUrl(urlString)) { + logger.warn(`Blocked private/internal URL: ${urlString}`) + return false + } + return true +} + +/** Escapes XML special characters for safe SVG interpolation. */ +export function escapeXml(str: string): string { + return str + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +/** Strips API key values from a URL string for safe logging. */ +export function redactUrl(url: string): string { + return url.replace(/([?&])(api_key|API_KEY)=[^&]*/gi, "$1$2=[REDACTED]") +} + +/** Header names allowed in user-supplied tileRequestHeader. */ +const ALLOWED_TILE_HEADERS = new Set([ + "user-agent", + "accept", + "accept-language", + "referer", + "cache-control", +]) + +/** Strips unsafe headers from user-supplied tile request headers. */ +export function sanitizeTileHeaders( + headers: Record | undefined +): Record { + if (!headers || typeof headers !== "object") return {} + + const sanitized: Record = {} + for (const [key, value] of Object.entries(headers)) { + if (ALLOWED_TILE_HEADERS.has(key.toLowerCase()) && typeof value === "string" && !/[\r\n]/.test(value)) { + sanitized[key] = value + } + } + return sanitized +} From a48c0813477d0d0cfe5468af53246ced9d334158 Mon Sep 17 00:00:00 2001 From: Max Dietrich Date: Sat, 21 Feb 2026 22:09:15 +0100 Subject: [PATCH 2/3] replace regex-based placeholder substitution with explicit replaceAll --- src/generate/generateParams.ts | 4 ++-- src/generate/parseTileConfig.ts | 4 ++-- src/utils/security.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/generate/generateParams.ts b/src/generate/generateParams.ts index 16789b1..1cae8e2 100644 --- a/src/generate/generateParams.ts +++ b/src/generate/generateParams.ts @@ -9,7 +9,7 @@ import logger from "../utils/logger" import { parseCenter } from "./parseCoordinates" import { parseMultipleShapes } from "./parseShapes" import { getTileUrl, parseAttributionParam, parseBorderParam } from "./parseTileConfig" -import { sanitizeTileHeaders, isPrivateUrl } from "../utils/security" +import { sanitizeTileHeaders, isPrivateUrl, replacePlaceholders } from "../utils/security" // Re-export submodule functions for backward compatibility with tests export { isEncodedPolyline, parseCoordinates, parseCenter } from "./parseCoordinates" @@ -242,7 +242,7 @@ function validateTileLayers(layers: any): any[] { if (!Array.isArray(layers)) return [] return layers.slice(0, 10).map((layer: any) => { if (!layer || typeof layer !== "object") return {} - const testUrl = (layer.tileUrl || "").replace(/\{[^}]+\}/g, "0") + const testUrl = replacePlaceholders(layer.tileUrl || "") if (layer.tileUrl && isPrivateUrl(testUrl)) { logger.error(`Blocked private/internal tile layer URL: ${layer.tileUrl}`) return { ...layer, tileUrl: "" } diff --git a/src/generate/parseTileConfig.ts b/src/generate/parseTileConfig.ts index 88ca08a..f49869f 100644 --- a/src/generate/parseTileConfig.ts +++ b/src/generate/parseTileConfig.ts @@ -1,6 +1,6 @@ import { basemaps } from "../utils/basemaps" import logger from "../utils/logger" -import { isPrivateUrl } from "../utils/security" +import { isPrivateUrl, replacePlaceholders } from "../utils/security" /** * Generates a tile URL and attribution based on the provided custom URL and basemap. @@ -14,7 +14,7 @@ export function getTileUrl( basemapName: string | null ): { url: string; attribution: string } { if (customUrl) { - const testUrl = customUrl.replace(/\{[^}]+\}/g, "0") + const testUrl = replacePlaceholders(customUrl) if (isPrivateUrl(testUrl)) { logger.error(`Blocked private/internal tile URL: ${customUrl}`) return { url: "", attribution: "" } diff --git a/src/utils/security.ts b/src/utils/security.ts index f3c6cab..6894da1 100644 --- a/src/utils/security.ts +++ b/src/utils/security.ts @@ -86,6 +86,17 @@ export function redactUrl(url: string): string { return url.replace(/([?&])(api_key|API_KEY)=[^&]*/gi, "$1$2=[REDACTED]") } +/** Replaces tile URL template placeholders like {z}, {x}, {y}, {s}, {quadkey} with safe defaults. */ +export function replacePlaceholders(url: string): string { + return url + .replaceAll("{z}", "0") + .replaceAll("{x}", "0") + .replaceAll("{y}", "0") + .replaceAll("{s}", "a") + .replaceAll("{quadkey}", "0") + .replaceAll("{r}", "") +} + /** Header names allowed in user-supplied tileRequestHeader. */ const ALLOWED_TILE_HEADERS = new Set([ "user-agent", From 58fefa00ae2fab5438c6ac733016176f78eeb152 Mon Sep 17 00:00:00 2001 From: Max Dietrich Date: Sat, 21 Feb 2026 22:11:00 +0100 Subject: [PATCH 3/3] fixed test --- __tests__/staticmaps/renderer.markers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/staticmaps/renderer.markers.test.ts b/__tests__/staticmaps/renderer.markers.test.ts index a8be18f..18a2809 100644 --- a/__tests__/staticmaps/renderer.markers.test.ts +++ b/__tests__/staticmaps/renderer.markers.test.ts @@ -221,7 +221,7 @@ describe("loadMarkers", () => { expect(global.fetch).toHaveBeenCalledWith( "https://example.com/marker.png", - { method: "GET", redirect: "manual" } + expect.objectContaining({ method: "GET", redirect: "manual" }) ) expect(sharpMock).toHaveBeenCalled() expect(sharpInstance.resize).toHaveBeenCalledWith(10, 15)