From bff06824d86128a91fd424891f07083fbf0693eb Mon Sep 17 00:00:00 2001 From: Florian Zumpe Date: Wed, 30 Sep 2026 01:17:55 +0200 Subject: [PATCH] check if routes are writable or published --- src/routes/api.js | 35 ++++--- src/services/route-write-access.js | 54 +++++++++++ test/route-write-access.test.js | 151 +++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 src/services/route-write-access.js create mode 100644 test/route-write-access.test.js diff --git a/src/routes/api.js b/src/routes/api.js index c7e90fc..c4a02a4 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -5,6 +5,7 @@ import { listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute, listPois, getPoi, createPoi, updatePoi, deletePoi, softDeleteRoute, restoreRoute } from '../services/routes-service.js'; +import { assertRouteDeleteAllowed, assertRouteWritable } from '../services/route-write-access.js'; import { listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture, getAudio, getAudioFile, createAudio, updateAudio, deleteAudio @@ -31,6 +32,14 @@ function sendFileResource(res, file, contentType = null) { export function createApiRouter(db) { const api = Router(); + const requireWritableRoute = paramName => (req, res, next) => { + assertRouteWritable(db, Number(req.params[paramName])); + next(); + }; + const requireRouteDeleteAllowed = paramName => (req, res, next) => { + assertRouteDeleteAllowed(db, Number(req.params[paramName]), req); + next(); + }; api.use((req, res, next) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); @@ -83,22 +92,22 @@ export function createApiRouter(db) { res.status(201).location(`/api/routes/${route.id}`).json(route); }); - api.put('/routes/:id', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => { + api.put('/routes/:id', requireWritableRoute('id'), upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => { res.json(updateRoute(db, Number(req.params.id), req.body, req.file)); }); - api.post('/routes/:id/append', upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => { + api.post('/routes/:id/append', requireWritableRoute('id'), upload.single('gpx'), inspectUpload('gpx', 'gpx'), (req, res) => { res.json(appendRoute(db, Number(req.params.id), req.file)); }); - api.post('/routes/:id/pois', upload.none(), (req, res) => { + api.post('/routes/:id/pois', requireWritableRoute('id'), upload.none(), (req, res) => { const poi = createPoi(db, Number(req.params.id), req.body); res.status(201) .location(`/api/routes/${req.params.id}/pois/${poi.id}`) .json(poi); }); - api.put('/routes/:routeId/pois/:poiId', upload.none(), (req, res) => { + api.put('/routes/:routeId/pois/:poiId', requireWritableRoute('routeId'), upload.none(), (req, res) => { res.json(updatePoi( db, Number(req.params.routeId), @@ -107,7 +116,7 @@ export function createApiRouter(db) { )); }); - api.delete('/routes/:routeId/pois/:poiId', (req, res) => { + api.delete('/routes/:routeId/pois/:poiId', requireRouteDeleteAllowed('routeId'), (req, res) => { res.json(deletePoi(db, Number(req.params.routeId), Number(req.params.poiId))); }); @@ -126,7 +135,7 @@ export function createApiRouter(db) { sendFileResource(res, getPictureFile(db, routeId, poiId, pictureId)); }); - api.post('/routes/:routeId/pois/:poiId/pictures', upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => { + api.post('/routes/:routeId/pois/:poiId/pictures', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => { const routeId = Number(req.params.routeId); const poiId = Number(req.params.poiId); const picture = createPicture(db, routeId, poiId, req.body, req.file); @@ -135,7 +144,7 @@ export function createApiRouter(db) { .json(picture); }); - api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => { + api.put('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireWritableRoute('routeId'), upload.single('picture'), inspectUpload('picture', 'picture', { allowJson: true }), (req, res) => { res.json(updatePicture( db, Number(req.params.routeId), @@ -146,7 +155,7 @@ export function createApiRouter(db) { )); }); - api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', (req, res) => { + api.delete('/routes/:routeId/pois/:poiId/pictures/:pictureId', requireRouteDeleteAllowed('routeId'), (req, res) => { res.json(deletePicture( db, Number(req.params.routeId), @@ -165,7 +174,7 @@ export function createApiRouter(db) { sendFileResource(res, getAudioFile(db, routeId, poiId)); }); - api.post('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => { + api.post('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => { const routeId = Number(req.params.routeId); const poiId = Number(req.params.poiId); const audio = createAudio(db, routeId, poiId, req.file); @@ -174,19 +183,19 @@ export function createApiRouter(db) { .json(audio); }); - api.put('/routes/:routeId/pois/:poiId/audio', upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => { + api.put('/routes/:routeId/pois/:poiId/audio', requireWritableRoute('routeId'), upload.single('audio'), inspectUpload('audio', 'audio', { allowJson: true }), (req, res) => { res.json(updateAudio(db, Number(req.params.routeId), Number(req.params.poiId), req.file)); }); - api.delete('/routes/:routeId/pois/:poiId/audio', (req, res) => { + api.delete('/routes/:routeId/pois/:poiId/audio', requireRouteDeleteAllowed('routeId'), (req, res) => { res.json(deleteAudio(db, Number(req.params.routeId), Number(req.params.poiId))); }); - api.delete('/routes/:id', (req, res) => { + api.delete('/routes/:id', requireRouteDeleteAllowed('id'), (req, res) => { res.json({ route: softDeleteRoute(db, Number(req.params.id)), softDeleted: true }); }); - api.post('/routes/:id/restore', (req, res) => { + api.post('/routes/:id/restore', requireWritableRoute('id'), (req, res) => { res.json({ route: restoreRoute(db, Number(req.params.id)), restored: true }); }); diff --git a/src/services/route-write-access.js b/src/services/route-write-access.js new file mode 100644 index 0000000..7906b21 --- /dev/null +++ b/src/services/route-write-access.js @@ -0,0 +1,54 @@ +import { HttpError } from '../middleware/errors.js'; + +function getRouteState(db, routeId) { + const route = db.prepare(`SELECT id, activated_at AS activatedAt + FROM routes WHERE id = ?`).get(routeId); + if (!route) throw new HttpError(404, 'Strecke nicht gefunden.'); + return route; +} + +function hasForwardingHeader(req, name) { + const value = req.headers?.[name]; + if (Array.isArray(value)) return value.some(item => String(item).trim().length > 0); + return value != null && String(value).trim().length > 0; +} + +function isLoopbackAddress(address) { + if (typeof address !== 'string' || address.length === 0) return false; + if (address === '::1') return true; + + const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address; + return /^127(?:\.\d{1,3}){3}$/.test(ipv4); +} + +export function isDirectLocalRequest(req) { + if (!isLoopbackAddress(req.socket?.remoteAddress)) return false; + + return ![ + 'forwarded', + 'x-forwarded-for', + 'x-real-ip' + ].some(header => hasForwardingHeader(req, header)); +} + +export function assertRouteWritable(db, routeId) { + const route = getRouteState(db, routeId); + if (route.activatedAt != null) { + throw new HttpError(409, 'Freigegebene Strecken können nicht mehr verändert werden.'); + } + return route; +} + +export function assertRouteDeleteAllowed(db, routeId, req) { + const route = getRouteState(db, routeId); + if (route.activatedAt == null) return route; + + if (!isDirectLocalRequest(req)) { + throw new HttpError( + 403, + 'Freigegebene Strecken dürfen nur durch einen direkten lokalen Serveraufruf gelöscht werden.' + ); + } + + return route; +} diff --git a/test/route-write-access.test.js b/test/route-write-access.test.js new file mode 100644 index 0000000..5f907e7 --- /dev/null +++ b/test/route-write-access.test.js @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { + assertRouteDeleteAllowed, + assertRouteWritable, + isDirectLocalRequest +} from '../src/services/route-write-access.js'; + +const root = path.resolve(import.meta.dirname, '..'); + +function fakeDatabase(route) { + return { + prepare(sql) { + assert.match(sql, /SELECT id, activated_at AS activatedAt\s+FROM routes WHERE id = \?/i); + return { + get(routeId) { + if (!route || Number(route.id) !== Number(routeId)) return undefined; + return route; + } + }; + } + }; +} + +function request(remoteAddress, headers = {}) { + return { + headers, + socket: { remoteAddress } + }; +} + +function assertHttpError(expectedStatus, expectedMessage) { + return error => { + assert.equal(error.status, expectedStatus); + assert.match(error.message, expectedMessage); + return true; + }; +} + +test('nicht freigegebene Strecken bleiben schreibbar', () => { + const db = fakeDatabase({ id: 7, activatedAt: null }); + assert.deepEqual(assertRouteWritable(db, 7), { id: 7, activatedAt: null }); +}); + +test('freigegebene Strecken blockieren normale Schreibzugriffe', () => { + const db = fakeDatabase({ id: 7, activatedAt: '2026-09-29 10:00:00' }); + assert.throws( + () => assertRouteWritable(db, 7), + assertHttpError(409, /nicht mehr verändert/i) + ); +}); + +test('die Freigabeprüfung meldet unbekannte Strecken als nicht gefunden', () => { + const db = fakeDatabase(null); + assert.throws( + () => assertRouteWritable(db, 999), + assertHttpError(404, /nicht gefunden/i) + ); +}); + +test('direkte Loopback-Aufrufe werden als lokal erkannt', () => { + assert.equal(isDirectLocalRequest(request('127.0.0.1')), true); + assert.equal(isDirectLocalRequest(request('127.0.0.42')), true); + assert.equal(isDirectLocalRequest(request('::1')), true); + assert.equal(isDirectLocalRequest(request('::ffff:127.0.0.1')), true); +}); + +test('externe und weitergeleitete Aufrufe gelten nicht als direkt lokal', () => { + assert.equal(isDirectLocalRequest(request('192.0.2.10')), false); + assert.equal(isDirectLocalRequest(request('127.0.0.1', { + 'x-forwarded-for': '198.51.100.25' + })), false); + assert.equal(isDirectLocalRequest(request('127.0.0.1', { + 'x-real-ip': '198.51.100.25' + })), false); + assert.equal(isDirectLocalRequest(request('::1', { + forwarded: 'for=198.51.100.25;proto=https' + })), false); +}); + +test('DELETE bleibt für nicht freigegebene Strecken auch remote zulässig', () => { + const db = fakeDatabase({ id: 7, activatedAt: null }); + assert.deepEqual( + assertRouteDeleteAllowed(db, 7, request('192.0.2.10')), + { id: 7, activatedAt: null } + ); +}); + +test('DELETE einer freigegebenen Strecke ist nur direkt lokal zulässig', () => { + const db = fakeDatabase({ id: 7, activatedAt: '2026-09-29 10:00:00' }); + + assert.deepEqual( + assertRouteDeleteAllowed(db, 7, request('127.0.0.1')), + { id: 7, activatedAt: '2026-09-29 10:00:00' } + ); + + assert.throws( + () => assertRouteDeleteAllowed(db, 7, request('192.0.2.10')), + assertHttpError(403, /direkten lokalen Serveraufruf/i) + ); + + assert.throws( + () => assertRouteDeleteAllowed(db, 7, request('127.0.0.1', { + 'x-forwarded-for': '198.51.100.25' + })), + assertHttpError(403, /direkten lokalen Serveraufruf/i) + ); +}); + +test('alle vorhandenen Schreibpfade verwenden die zentrale Freigabeprüfung', async () => { + const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8'); + + const writablePatterns = [ + /api\.put\('\/routes\/:id',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/, + /api\.post\('\/routes\/:id\/append',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/, + /api\.post\('\/routes\/:id\/pois',\s*requireWritableRoute\('id'\),\s*upload\.none\(\)/, + /api\.put\('\/routes\/:routeId\/pois\/:poiId',\s*requireWritableRoute\('routeId'\),\s*upload\.none\(\)/, + /api\.post\('\/routes\/:routeId\/pois\/:poiId\/pictures',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/, + /api\.put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/, + /api\.post\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/, + /api\.put\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/, + /api\.post\('\/routes\/:id\/restore',\s*requireWritableRoute\('id'\)/ + ]; + + for (const pattern of writablePatterns) assert.match(api, pattern); +}); + +test('Route-, POI-, Bild- und Audio-DELETE verwenden die lokale Freigabeausnahme', async () => { + const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8'); + + const deletePatterns = [ + /api\.delete\('\/routes\/:id',\s*requireRouteDeleteAllowed\('id'\)/, + /api\.delete\('\/routes\/:routeId\/pois\/:poiId',\s*requireRouteDeleteAllowed\('routeId'\)/, + /api\.delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireRouteDeleteAllowed\('routeId'\)/, + /api\.delete\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireRouteDeleteAllowed\('routeId'\)/ + ]; + + for (const pattern of deletePatterns) assert.match(api, pattern); +}); + + +test('nginx kennzeichnet weitergeleitete Internet-Aufrufe für die Local-DELETE-Prüfung', async () => { + const nginx = await fs.readFile(path.join(root, 'nginx/wegwichtel.conf'), 'utf8'); + + assert.match(nginx, /proxy_pass\s+http:\/\/127\.0\.0\.1:47145;/); + assert.match(nginx, /proxy_set_header\s+X-Real-IP\s+\$remote_addr;/); + assert.match(nginx, /proxy_set_header\s+X-Forwarded-For\s+\$proxy_add_x_forwarded_for;/); +});