From b268cfdd8fc624fedfa804bc833ebee98ca76023 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 26 Jan 2026 18:34:21 -0500 Subject: [PATCH] [api] reject requests from clients using outdated versions --- packages/api/package.json | 2 + packages/api/src/api.ts | 37 ++++++++++++++ packages/api/test/api.test.ts | 91 ++++++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/packages/api/package.json b/packages/api/package.json index 9fb4b8f..d0db9b9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,6 +13,7 @@ "@types/busboy": "^1.5.4", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.10", + "@types/semver": "^7.7.1", "@types/supertest": "^6.0.3", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -29,6 +30,7 @@ "jsonwebtoken": "^9.0.2", "pino": "^10.2.1", "pino-http": "^11.0.0", + "semver": "^7.7.3", "uuid": "^13.0.0" } } diff --git a/packages/api/src/api.ts b/packages/api/src/api.ts index 0c62915..07749b6 100644 --- a/packages/api/src/api.ts +++ b/packages/api/src/api.ts @@ -11,6 +11,7 @@ import { unlink } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { createS3Storage, S3Config } from "./s3.js"; +import semver from "semver"; import { pipeline } from "stream/promises"; import asyncHandler from "express-async-handler"; import { getLogger } from "./logger.js"; @@ -31,6 +32,8 @@ const { NOAA_CSB_TOKEN = "test", } = process.env; +export const MIN_CLIENT_VERSION = "1.0.1"; + export type APIOptions = { url?: string; token?: string; @@ -70,6 +73,7 @@ export function registerWithRouter(router: IRouter, options: APIOptions = {}) { router.post( "/geojson", verifyIdentity, + verifyClientVersion, asyncHandler(async (req, res) => { let metadata: MultipartMetadata; let data: Readable; @@ -232,6 +236,39 @@ export function verifyIdentity( }); } +export function verifyClientVersion( + req: Request, + res: Response, + next: NextFunction, +) { + logger.trace("Verifying client version for request to %s", req.path); + const ua = req.headers["user-agent"]; + + // Expect format: crowd-depth/ (...) + const [, clientVersion] = ua?.match(/^crowd-depth\/([^\s]+)(\s|$)/) || []; + if (!semver.valid(clientVersion)) { + logger.warn("Missing or invalid User-Agent: %s", ua); + return res + .status(400) + .json({ success: false, message: "Missing or invalid User-Agent" }); + } + + if (semver.lt(clientVersion, MIN_CLIENT_VERSION)) { + logger.warn( + "Client version %s below minimum %s", + clientVersion, + MIN_CLIENT_VERSION, + ); + return res.status(426).json({ + success: false, + message: "Client version too old", + minVersion: MIN_CLIENT_VERSION, + }); + } + + next(); +} + export function createIdentity(uuid = uuidv4()) { logger.debug("Creating identity for uuid: %s", uuid); return { diff --git a/packages/api/test/api.test.ts b/packages/api/test/api.test.ts index 6aac53a..50dcf3f 100644 --- a/packages/api/test/api.test.ts +++ b/packages/api/test/api.test.ts @@ -2,7 +2,12 @@ import { describe, test, expect, beforeAll } from "vitest"; import request from "supertest"; import express from "express"; import nock from "nock"; -import { createApi, createIdentity, type APIOptions } from "../src/api.js"; +import { + createApi, + createIdentity, + MIN_CLIENT_VERSION, + type APIOptions, +} from "../src/api.js"; import { toUniqueID } from "crowd-depth"; import { vessel } from "../../signalk-plugin/test/helper.js"; @@ -27,6 +32,74 @@ function useApp(options: APIOptions = {}) { } describe("POST /geojson", () => { + describe("client version validation", () => { + test("rejects requests without User-Agent", async () => { + await useApp() + .post("/geojson") + .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .expect(400) + .expect({ success: false, message: "Missing or invalid User-Agent" }); + }); + + test("rejects requests with invalid User-Agent format", async () => { + await useApp() + .post("/geojson") + .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set("User-Agent", "SomeOtherClient/1.0.0") + .expect(400) + .expect({ success: false, message: "Missing or invalid User-Agent" }); + }); + + test("rejects requests with unparseable version", async () => { + await useApp() + .post("/geojson") + .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set("User-Agent", "crowd-depth/not-a-version (https://github.com)") + .expect(400) + .expect({ success: false, message: "Missing or invalid User-Agent" }); + }); + + test("rejects requests with version below minimum (1.0.0)", async () => { + await useApp() + .post("/geojson") + .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set( + "User-Agent", + "crowd-depth/1.0.0 (https://github.com/openwatersio/crowd-depth)", + ) + .expect(426) + .expect({ + success: false, + message: "Client version too old", + minVersion: MIN_CLIENT_VERSION, + }); + }); + + test("accepts requests with minimum version", async () => { + await useApp() + .post("/geojson") + .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set( + "User-Agent", + `crowd-depth/${MIN_CLIENT_VERSION} (https://github.com/openwatersio/crowd-depth)`, + ) + .expect(400) // Will fail at the next validation step (missing data) + .expect({ success: false, message: "Missing Content-Type" }); + }); + + test("accepts requests with version above minimum", async () => { + await useApp() + .post("/geojson") + .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set( + "User-Agent", + "crowd-depth/2000.0.0 (https://github.com/openwatersio/crowd-depth)", + ) + .expect(400) // Will fail at the next validation step (missing data) + .expect({ success: false, message: "Missing Content-Type" }); + }); + }); + test("rejects requests without token", async () => { await useApp() .post("/geojson") @@ -54,6 +127,10 @@ describe("POST /geojson", () => { await useApp() .post("/geojson") .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set( + "User-Agent", + `crowd-depth/${MIN_CLIENT_VERSION} (https://github.com/openwatersio/crowd-depth)`, + ) .expect(400) .expect({ success: false, message: "Missing Content-Type" }); }); @@ -65,6 +142,10 @@ describe("POST /geojson", () => { await useApp() .post("/geojson") .set("Authorization", `Bearer ${token}`) + .set( + "User-Agent", + `crowd-depth/${MIN_CLIENT_VERSION} (https://github.com/openwatersio/crowd-depth)`, + ) .field("metadataInput", JSON.stringify({ uniqueID }), { filename: "test.json", contentType: "application/json", @@ -89,6 +170,10 @@ describe("POST /geojson", () => { await useApp() .post("/geojson") .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set( + "User-Agent", + `crowd-depth/${MIN_CLIENT_VERSION} (https://github.com/openwatersio/crowd-depth)`, + ) .field("metadataInput", JSON.stringify({ uniqueID }), { filename: "test.json", contentType: "application/json", @@ -131,6 +216,10 @@ describe("POST /geojson", () => { await useApp({ env }) .post("/geojson") .set("Authorization", `Bearer ${createIdentity(vessel.uuid).token}`) + .set( + "User-Agent", + `crowd-depth/${MIN_CLIENT_VERSION} (https://github.com/openwatersio/crowd-depth)`, + ) .field("metadataInput", JSON.stringify({ uniqueID }), { filename: "test.json", contentType: "application/json",