From 2703057f377b81dac4514def71d8160f5fc6e065 Mon Sep 17 00:00:00 2001 From: cclavin <31264358+cclavin@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:21:01 -0500 Subject: [PATCH] fix(maps): serve map assets with CORS origin: ['*'] in config/cors.ts never matches a real Origin header, since @adonisjs/cors only treats '*' as a wildcard when it's the bare string, not inside an array. So no Access-Control-Allow-Origin is ever sent, on any route, blocking any other app from reading NOMAD's map tiles cross-origin. Add a middleware scoped to the four map asset path prefixes instead of widening the global config, which would also expose the write API since credentials: true is already set there. Registered before @adonisjs/cors so it can answer OPTIONS preflights on map paths itself; the built-in middleware ends any preflight it can't match an origin for before this one would otherwise run. --- admin/app/middleware/maps_cors_middleware.ts | 34 ++++++++++++++++++++ admin/start/kernel.ts | 3 ++ admin/tests/unit/maps_cors.spec.ts | 23 +++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 admin/app/middleware/maps_cors_middleware.ts create mode 100644 admin/tests/unit/maps_cors.spec.ts diff --git a/admin/app/middleware/maps_cors_middleware.ts b/admin/app/middleware/maps_cors_middleware.ts new file mode 100644 index 00000000..f161ad53 --- /dev/null +++ b/admin/app/middleware/maps_cors_middleware.ts @@ -0,0 +1,34 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +// origin: ['*'] in config/cors.ts never matches a real Origin header +// (@adonisjs/cors only wildcards on the bare string origin: '*'), and a +// global fix would also expose the write API since credentials: true is +// set. Scope anonymous, read-only CORS to the map asset paths instead. + +const MAP_ASSET_PREFIXES = ['/api/maps/', '/pmtiles/', '/basemaps-assets/', '/storage/maps/'] + +export function isMapAssetPath(pathname: string): boolean { + return MAP_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix)) +} + +export default class MapsCorsMiddleware { + async handle({ request, response }: HttpContext, next: NextFn) { + if (!isMapAssetPath(request.url())) return next() + + // safeHeader so this stays inert if the global CORS config is ever fixed. + response.safeHeader('Access-Control-Allow-Origin', '*') + response.header('Access-Control-Expose-Headers', 'ETag, Content-Range, Accept-Ranges, Content-Length') + response.header('Vary', 'Origin') + + if (request.method() === 'OPTIONS') { + // serve-static only handles GET/HEAD, so OPTIONS would otherwise 404. + response.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS') + response.header('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Match') + response.header('Access-Control-Max-Age', '600') + return response.status(204) + } + + return next() + } +} diff --git a/admin/start/kernel.ts b/admin/start/kernel.ts index cde1fb0e..7ea6e7c2 100644 --- a/admin/start/kernel.ts +++ b/admin/start/kernel.ts @@ -24,6 +24,9 @@ server.errorHandler(() => import('#exceptions/handler')) */ server.use([ () => import('#middleware/container_bindings_middleware'), + // Must precede @adonisjs/cors: it ends unmatched-origin preflights (#endPreFlight) + // before this middleware would ever see them, since origin: ['*'] never matches. + () => import('#middleware/maps_cors_middleware'), () => import('@adonisjs/cors/cors_middleware'), () => import('@adonisjs/vite/vite_middleware'), () => import('@adonisjs/inertia/inertia_middleware'), diff --git a/admin/tests/unit/maps_cors.spec.ts b/admin/tests/unit/maps_cors.spec.ts new file mode 100644 index 00000000..71db88f4 --- /dev/null +++ b/admin/tests/unit/maps_cors.spec.ts @@ -0,0 +1,23 @@ +import * as assert from 'node:assert/strict' +import { test } from 'node:test' + +import { isMapAssetPath } from '../../app/middleware/maps_cors_middleware.js' + +test('isMapAssetPath accepts map style, pmtiles, and basemaps-assets paths', () => { + for (const path of [ + '/api/maps/styles', + '/api/maps/regions', + '/pmtiles/washington.pmtiles', + '/basemaps-assets/fonts/Noto%20Sans%20Regular/0-255.pbf', + '/basemaps-assets/sprites/v4/light.json', + '/storage/maps/pmtiles/washington.pmtiles', + ]) { + assert.equal(isMapAssetPath(path), true, `${path} should be a map asset path`) + } +}) + +test('isMapAssetPath rejects non-map paths, including a near-miss without the trailing slash', () => { + for (const path of ['/api/system/info', '/api/maps', '/api/mapsfoo', '/maps', '/', '/pmtiles', '/basemaps-assets']) { + assert.equal(isMapAssetPath(path), false, `${path} should not be a map asset path`) + } +})