From 442142d1ee74f78ec3ee779ff80d34b4e1fba406 Mon Sep 17 00:00:00 2001 From: cclavin <31264358+cclavin@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:37:26 -0500 Subject: [PATCH] fix(maps): gate CORS on method, narrow /api/maps/ to /styles /api/maps/ has ten POST/PATCH/DELETE routes under it (download-remote, extract, marker CRUD, and others). The middleware set Access-Control-Allow-Origin before checking the method, so those responses got the header too. Gate emission to GET/HEAD/OPTIONS. /styles is the only /api/maps/ route another app needs cross-origin (confirmed against the app that surfaced this bug); match it exactly instead of the whole prefix. The other three prefixes are served by AdonisJS's static provider, GET/HEAD only, so they're unaffected. --- admin/app/middleware/maps_cors_middleware.ts | 15 ++++++++--- admin/tests/unit/maps_cors.spec.ts | 28 ++++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/admin/app/middleware/maps_cors_middleware.ts b/admin/app/middleware/maps_cors_middleware.ts index f161ad53..b39dff1d 100644 --- a/admin/app/middleware/maps_cors_middleware.ts +++ b/admin/app/middleware/maps_cors_middleware.ts @@ -4,17 +4,24 @@ 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. +// set. Scope anonymous, read-only CORS to map tiles/assets and the one +// JSON route another app actually reads, GET/HEAD/OPTIONS only. -const MAP_ASSET_PREFIXES = ['/api/maps/', '/pmtiles/', '/basemaps-assets/', '/storage/maps/'] +const MAP_ASSET_EXACT_PATHS = ['/api/maps/styles'] +const MAP_ASSET_PREFIXES = ['/pmtiles/', '/basemaps-assets/', '/storage/maps/'] +const READABLE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) export function isMapAssetPath(pathname: string): boolean { - return MAP_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix)) + return MAP_ASSET_EXACT_PATHS.includes(pathname) || MAP_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix)) +} + +export function isReadableMethod(method: string): boolean { + return READABLE_METHODS.has(method) } export default class MapsCorsMiddleware { async handle({ request, response }: HttpContext, next: NextFn) { - if (!isMapAssetPath(request.url())) return next() + if (!isMapAssetPath(request.url()) || !isReadableMethod(request.method())) return next() // safeHeader so this stays inert if the global CORS config is ever fixed. response.safeHeader('Access-Control-Allow-Origin', '*') diff --git a/admin/tests/unit/maps_cors.spec.ts b/admin/tests/unit/maps_cors.spec.ts index 71db88f4..64b365fa 100644 --- a/admin/tests/unit/maps_cors.spec.ts +++ b/admin/tests/unit/maps_cors.spec.ts @@ -1,12 +1,11 @@ import * as assert from 'node:assert/strict' import { test } from 'node:test' -import { isMapAssetPath } from '../../app/middleware/maps_cors_middleware.js' +import { isMapAssetPath, isReadableMethod } from '../../app/middleware/maps_cors_middleware.js' -test('isMapAssetPath accepts map style, pmtiles, and basemaps-assets paths', () => { +test('isMapAssetPath accepts the styles route, 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', @@ -16,8 +15,27 @@ test('isMapAssetPath accepts map style, pmtiles, and basemaps-assets paths', () } }) -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']) { +test('isMapAssetPath rejects non-map paths and other /api/maps/ routes', () => { + for (const path of [ + '/api/system/info', + '/api/maps', + '/api/mapsfoo', + '/api/maps/regions', + '/api/maps/download-remote', + '/maps', + '/', + '/pmtiles', + '/basemaps-assets', + ]) { assert.equal(isMapAssetPath(path), false, `${path} should not be a map asset path`) } }) + +test('isReadableMethod accepts GET, HEAD, and OPTIONS, rejects write methods', () => { + for (const method of ['GET', 'HEAD', 'OPTIONS']) { + assert.equal(isReadableMethod(method), true, `${method} should be readable`) + } + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + assert.equal(isReadableMethod(method), false, `${method} should not be readable`) + } +})