check if routes are writable or published
This commit is contained in:
+22
-13
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;/);
|
||||
});
|
||||
Reference in New Issue
Block a user