mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
77162aa8d5
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
215 lines
9.9 KiB
TypeScript
215 lines
9.9 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import { applyGlobalMiddleware, routingCspOrigins } from '../../../src/middleware/globalMiddleware';
|
|
|
|
async function directiveSources(name: string): Promise<string[]> {
|
|
const app = express();
|
|
applyGlobalMiddleware(app);
|
|
app.get('/probe', (_req, res) => res.json({ ok: true }));
|
|
|
|
const res = await request(app).get('/probe');
|
|
const csp = String(res.headers['content-security-policy'] || '');
|
|
const directive = csp
|
|
.split(';')
|
|
.map(d => d.trim())
|
|
.find(d => d.startsWith(name));
|
|
|
|
return directive ? directive.split(/\s+/).slice(1) : [];
|
|
}
|
|
|
|
const connectSrcSources = () => directiveSources('connect-src');
|
|
|
|
describe('global CSP: OpenStreetMap tile hosts (#1733)', () => {
|
|
it('allows the bare tile.openstreetmap.org host', async () => {
|
|
// A CSP wildcard host never matches the apex, so `*.tile.openstreetmap.org`
|
|
// alone would block the tile prefetcher's fetch() against the single host
|
|
// OSM has served from since it retired a/b/c/d sharding.
|
|
expect(await connectSrcSources()).toContain('https://tile.openstreetmap.org');
|
|
});
|
|
|
|
it('still allows the sharded hosts for templates saved earlier', async () => {
|
|
expect(await connectSrcSources()).toContain('https://*.tile.openstreetmap.org');
|
|
});
|
|
});
|
|
|
|
describe('global CSP: the other shipped raster presets (#2180)', () => {
|
|
it('allows tile.openstreetmap.de', async () => {
|
|
// The prefetcher fetches tiles with mode 'no-cors', which relaxes CORS and
|
|
// nothing else: a host missing here is refused in the document, so the
|
|
// Service Worker never sees the request and caches no tile at all.
|
|
expect(await connectSrcSources()).toContain('https://tile.openstreetmap.de');
|
|
});
|
|
|
|
it('allows tiles.stadiamaps.com', async () => {
|
|
expect(await connectSrcSources()).toContain('https://tiles.stadiamaps.com');
|
|
});
|
|
|
|
it('keeps the routing host, which is a different host and covers nothing here', async () => {
|
|
// routing.openstreetmap.de was on the list all along and looks close enough
|
|
// to hide the gap: a CSP source matches a host, not a suffix of one.
|
|
expect(await connectSrcSources()).toContain('https://routing.openstreetmap.de/');
|
|
});
|
|
});
|
|
|
|
describe('global CSP: the second routing engine', () => {
|
|
it('allows the public Valhalla, which is a shipped default like the OSRM hosts', async () => {
|
|
// It is asked only when a leg should avoid tolls, a motorway or a ferry — the
|
|
// question the OSRM hosts answer with HTTP 400, because their car profile carries
|
|
// no excludable classes. Left out here it fails the way CSP always fails a fetch:
|
|
// silently, with the switch working and no tile of an answer arriving.
|
|
expect(await connectSrcSources()).toContain('https://valhalla1.openstreetmap.de');
|
|
});
|
|
|
|
it('names the origin without a path, because it is a POST to /route', async () => {
|
|
// The OSRM entries carry `/route/v1/` because a source with a path matches that
|
|
// path alone, and OSRM is only ever asked there. Valhalla is a different shape and
|
|
// would grow endpoints (isochrones, map matching), so the origin is the right unit.
|
|
const sources = await connectSrcSources();
|
|
expect(sources).not.toContain('https://valhalla1.openstreetmap.de/route');
|
|
});
|
|
});
|
|
|
|
describe('global CSP: the imagery host (#2307)', () => {
|
|
it('allows server.arcgisonline.com, which a GL map reaches through fetch', async () => {
|
|
// The gap hid behind Leaflet for as long as Leaflet was the only renderer to
|
|
// show the imagery: it loads a tile as an <img>, and img-src allows `https:`
|
|
// outright. A GL map reads the raster through fetch to hand it to WebGL, so
|
|
// it lands on connect-src instead and was refused with nothing to see for it.
|
|
expect(await connectSrcSources()).toContain('https://server.arcgisonline.com');
|
|
});
|
|
|
|
it('names the apex host, because img-src is what used to carry it', async () => {
|
|
// img-src keeps its blanket `https:`, which is why Leaflet never noticed. The
|
|
// fix belongs on connect-src alone; widening img-src further would buy nothing
|
|
// and widening connect-src to a wildcard would not match the apex anyway.
|
|
const sources = await connectSrcSources();
|
|
expect(sources).not.toContain('https://*.arcgisonline.com');
|
|
expect(await directiveSources('img-src')).toContain('https:');
|
|
});
|
|
});
|
|
|
|
describe('global CSP: script-src', () => {
|
|
it("allows 'wasm-unsafe-eval' so the WASM decoders keep running", async () => {
|
|
expect(await directiveSources('script-src')).toContain("'wasm-unsafe-eval'");
|
|
});
|
|
|
|
it("still allows 'unsafe-eval', which heic-to needs to decode an iPhone photo", async () => {
|
|
// Pinned so the next tidy-up of this list finds the reason before the
|
|
// consequence: libheif initialises embind with new Function(), and without
|
|
// this every .heic upload fails in the browser. See the comment on the
|
|
// directive for how to actually get rid of it.
|
|
expect(await directiveSources('script-src')).toContain("'unsafe-eval'");
|
|
});
|
|
});
|
|
|
|
describe('forced-HTTPS redirect', () => {
|
|
const saved = { FORCE_HTTPS: process.env.FORCE_HTTPS, APP_URL: process.env.APP_URL };
|
|
|
|
afterEach(() => {
|
|
process.env.FORCE_HTTPS = saved.FORCE_HTTPS;
|
|
process.env.APP_URL = saved.APP_URL;
|
|
if (saved.FORCE_HTTPS === undefined) delete process.env.FORCE_HTTPS;
|
|
if (saved.APP_URL === undefined) delete process.env.APP_URL;
|
|
});
|
|
|
|
async function redirectLocation(): Promise<string> {
|
|
const app = express();
|
|
applyGlobalMiddleware(app);
|
|
app.get('/trips', (_req, res) => res.json({ ok: true }));
|
|
const res = await request(app).get('/trips').set('Host', 'evil.example.com');
|
|
return String(res.headers.location || '');
|
|
}
|
|
|
|
it('redirects to the configured APP_URL host, not the Host header the caller sent', async () => {
|
|
process.env.FORCE_HTTPS = 'true';
|
|
process.env.APP_URL = 'https://trip.pakulat.org';
|
|
expect(await redirectLocation()).toBe('https://trip.pakulat.org/trips');
|
|
});
|
|
|
|
it('falls back to the request host when APP_URL is unset', async () => {
|
|
process.env.FORCE_HTTPS = 'true';
|
|
delete process.env.APP_URL;
|
|
expect(await redirectLocation()).toBe('https://evil.example.com/trips');
|
|
});
|
|
|
|
it('falls back to the request host when APP_URL is not a URL', async () => {
|
|
// A typo in the env should not take the instance down, and it should not
|
|
// produce a redirect to a host built from a half-parsed string either.
|
|
process.env.FORCE_HTTPS = 'true';
|
|
process.env.APP_URL = 'not a url';
|
|
expect(await redirectLocation()).toBe('https://evil.example.com/trips');
|
|
});
|
|
|
|
it('leaves an already-secure request alone, and never redirects the health probe', async () => {
|
|
process.env.FORCE_HTTPS = 'true';
|
|
process.env.APP_URL = 'https://trip.pakulat.org';
|
|
const app = express();
|
|
applyGlobalMiddleware(app);
|
|
app.get('/trips', (_req, res) => res.json({ ok: true }));
|
|
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
|
|
|
// The proxy already terminated TLS, so there is nothing to upgrade.
|
|
const forwarded = await request(app).get('/trips').set('X-Forwarded-Proto', 'https');
|
|
expect(forwarded.status).toBe(200);
|
|
|
|
// The container probe talks plain HTTP on the loopback and must not be
|
|
// bounced to a hostname it cannot resolve.
|
|
const probe = await request(app).get('/api/health');
|
|
expect(probe.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('routingCspOrigins', () => {
|
|
it('CSP-ROUTING-001: keeps only the origin, because a path source matches that path alone', () => {
|
|
// A router is asked at several paths (/route/v1/…, /table/v1/…); pinning one would
|
|
// block the rest without an error the app could report.
|
|
expect(routingCspOrigins(['https://osrm.example.org/route/v1/driving']))
|
|
.toEqual(['https://osrm.example.org']);
|
|
});
|
|
|
|
it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => {
|
|
expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']);
|
|
});
|
|
|
|
it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => {
|
|
// A bad settings row must not be able to loosen the policy.
|
|
expect(routingCspOrigins(['', null, undefined, 'not a url', 'javascript:alert(1)', 'ftp://x/y']))
|
|
.toEqual([]);
|
|
});
|
|
|
|
it('CSP-ROUTING-004: the same host twice is one source', () => {
|
|
expect(routingCspOrigins([
|
|
'https://osrm.example.org/route/v1/driving',
|
|
'https://osrm.example.org/table/v1/driving',
|
|
])).toEqual(['https://osrm.example.org']);
|
|
});
|
|
|
|
it('CSP-ROUTING-006: both engines get named, because both are reached from the browser', () => {
|
|
// bootstrap hands in the OSRM default and the Valhalla default together. An
|
|
// instance that configures its own Valhalla and leaves OSRM public still has to
|
|
// have that host in the policy, and the other way round.
|
|
expect(routingCspOrigins([
|
|
'https://osrm.example.org/route/v1/driving',
|
|
'https://valhalla.example.org',
|
|
])).toEqual(['https://osrm.example.org', 'https://valhalla.example.org']);
|
|
expect(routingCspOrigins([null, 'https://valhalla.example.org'])).toEqual(['https://valhalla.example.org']);
|
|
});
|
|
});
|
|
|
|
describe('connect-src with a self-hosted router', () => {
|
|
it('CSP-ROUTING-005: a configured origin is named, or the browser blocks it silently', async () => {
|
|
const app = express();
|
|
applyGlobalMiddleware(app, { extraConnectSrc: ['https://osrm.example.org'] });
|
|
app.get('/probe', (_req, res) => res.json({ ok: true }));
|
|
|
|
const res = await request(app).get('/probe');
|
|
const csp = String(res.headers['content-security-policy'] || '');
|
|
const connect = csp.split(';').map(d => d.trim()).find(d => d.startsWith('connect-src'))!;
|
|
|
|
expect(connect).toContain('https://osrm.example.org');
|
|
// The public hosts stay, so an instance can be switched back without another deploy.
|
|
expect(connect).toContain('https://routing.openstreetmap.de/');
|
|
});
|
|
});
|