Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
37 changes: 37 additions & 0 deletions packages/api/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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/<version> (...)
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 {
Expand Down
91 changes: 90 additions & 1 deletion packages/api/test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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")
Expand Down Expand Up @@ -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" });
});
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down