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..18a2809 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" } + expect.objectContaining({ 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 @@
Current Static Map URL: - /staticmaps?/staticmaps?
diff --git a/src/generate/generateParams.ts b/src/generate/generateParams.ts index c814fa7..1cae8e2 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, replacePlaceholders } 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 = replacePlaceholders(layer.tileUrl || "") + 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..f49869f 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, replacePlaceholders } 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 = replacePlaceholders(customUrl) + 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