From 6ef949b3729db2eeb04dafad2a73491a7e0914c0 Mon Sep 17 00:00:00 2001 From: jhaydeeee-web Date: Mon, 19 Oct 2026 19:48:58 +0100 Subject: [PATCH 1/4] fix: restrict externalUrl to http(s) (#58) --- src/lib/externalUrlValidation.test.ts | 13 +++++++++++++ src/lib/externalUrlValidation.ts | 14 ++++++++++++++ src/schemas/requests.ts | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/lib/externalUrlValidation.test.ts create mode 100644 src/lib/externalUrlValidation.ts diff --git a/src/lib/externalUrlValidation.test.ts b/src/lib/externalUrlValidation.test.ts new file mode 100644 index 0000000..006b5a0 --- /dev/null +++ b/src/lib/externalUrlValidation.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { httpUrlSchema } from "./externalUrlValidation.js"; + +describe("httpUrlSchema", () => { + it("accepts http and https URLs", () => { + expect(httpUrlSchema.safeParse("https://example.com/x").success).toBe(true); + expect(httpUrlSchema.safeParse("http://example.com").success).toBe(true); + }); + it("rejects javascript: and data: URLs", () => { + expect(httpUrlSchema.safeParse("javascript:alert(1)").success).toBe(false); + expect(httpUrlSchema.safeParse("data:text/html,").success).toBe(false); + }); +}); diff --git a/src/lib/externalUrlValidation.ts b/src/lib/externalUrlValidation.ts new file mode 100644 index 0000000..ff9e5e6 --- /dev/null +++ b/src/lib/externalUrlValidation.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +/** A URL that must use the http: or https: scheme (rejects javascript:, data:, etc.). */ +export const httpUrlSchema = z.url().refine( + (value) => { + try { + const { protocol } = new URL(value); + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } + }, + { message: "URL must use http or https" }, +); diff --git a/src/schemas/requests.ts b/src/schemas/requests.ts index d309347..fbbffaa 100644 --- a/src/schemas/requests.ts +++ b/src/schemas/requests.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { config } from "../config.js"; import { webhookEventValues } from "./webhooks.js"; +import { httpUrlSchema } from "../lib/externalUrlValidation.js"; export const publisherRegisterSchema = z .object({ @@ -16,7 +17,7 @@ export const linkPublishSchema = z description: z.string().optional(), price: z.string().min(1), walletAddress: z.string().optional(), - externalUrl: z.url(), + externalUrl: httpUrlSchema, }) .strict(); From 6beca1e6c812fdc8ad214aca1402218e926c576c Mon Sep 17 00:00:00 2001 From: jhaydeeee-web Date: Mon, 19 Oct 2026 19:48:58 +0100 Subject: [PATCH 2/4] fix: bind compose Postgres to localhost and require password from env (#57) --- .env.example | 4 ++++ docker-compose.yml | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 02a1893..3906943 100644 --- a/.env.example +++ b/.env.example @@ -146,3 +146,7 @@ WORKERS_ENABLED=true # the database is unreachable. # [optional] Where the registry event listener checkpoints its last processed ledger # LEDGER_TRACKER_PATH=./last-processed-ledger.json + +# Password for the bundled docker-compose Postgres (required by docker compose). +# If you change it, update the password in DATABASE_URL above to match. +POSTGRES_PASSWORD=change-me diff --git a/docker-compose.yml b/docker-compose.yml index fd02c86..ae8de9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: env_file: .env environment: PORT: 3000 - DATABASE_URL: postgresql://synapse:synapse@postgres:5432/synapsvault + DATABASE_URL: postgresql://synapse:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@postgres:5432/synapsvault NODE_ENV: ${NODE_ENV:-development} # The ledger checkpoint is stored in Postgres; this file path is only # used as a fallback when the database is unavailable. @@ -45,12 +45,12 @@ services: container_name: synapsvault-postgres environment: POSTGRES_USER: synapse - POSTGRES_PASSWORD: synapse + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} POSTGRES_DB: synapsvault POSTGRES_INITDB_ARGS: "--encoding=UTF8" ports: - - "5432:5432" + - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data From 7c0e71fbee4c88e3b13c78cc733cab56d5b3fd15 Mon Sep 17 00:00:00 2001 From: jhaydeeee-web Date: Mon, 19 Oct 2026 19:48:58 +0100 Subject: [PATCH 3/4] fix: verify Postgres TLS certificate, support DATABASE_CA_CERT (#56) --- README.md | 5 ++++- src/db/client.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dca167a..4d68bc1 100644 --- a/README.md +++ b/README.md @@ -176,4 +176,7 @@ demand) to seed a testnet catalog and verify it end to end. ## License -MIT © 2025 Busiii-adetiba \ No newline at end of file +MIT © 2025 Busiii-adetiba +## Database TLS + +Outside localhost the Postgres server certificate is verified (`rejectUnauthorized: true`). If your database uses a private CA, set `DATABASE_CA_CERT` to the PEM-encoded CA bundle (literal `\n` sequences are accepted for single-line env vars). diff --git a/src/db/client.ts b/src/db/client.ts index 2144f4f..afc8c3d 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -12,8 +12,11 @@ const isLocal = dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1"); const sanitizedUrl = dbUrl.replace(/:([^:@]+)@/, ":***@"); rootLogger.info({ url: sanitizedUrl, ssl: !isLocal }, "DB connecting"); +// Verify the server certificate by default. A private CA can be supplied via +// DATABASE_CA_CERT (PEM contents; literal "\n" sequences are accepted). +const caCert = process.env.DATABASE_CA_CERT?.replace(/\\n/g, "\n"); const client = postgres(dbUrl, { - ssl: isLocal ? false : { rejectUnauthorized: false }, + ssl: isLocal ? false : { rejectUnauthorized: true, ...(caCert ? { ca: caCert } : {}) }, max: config.DB_POOL_MAX, // postgres.js takes these two in seconds; config is in milliseconds. idle_timeout: Math.ceil(config.DB_POOL_IDLE_TIMEOUT_MS / 1000), From 171e381ff770fcafd224796ab6ead7d79e17da10 Mon Sep 17 00:00:00 2001 From: jhaydeeee-web Date: Mon, 19 Oct 2026 19:49:08 +0100 Subject: [PATCH 4/4] fix: scope relaxed CSP to /docs only (#55) --- src/middleware/security.test.ts | 2 ++ src/middleware/security.ts | 55 +++++++++++++++++++++------------ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/middleware/security.test.ts b/src/middleware/security.test.ts index 5b13cff..f7366ea 100644 --- a/src/middleware/security.test.ts +++ b/src/middleware/security.test.ts @@ -25,6 +25,8 @@ describe("securityHeaders", () => { expect(res.headers["referrer-policy"]).toBeDefined(); // Helmet sets HSTS so production deployments are forced onto HTTPS. expect(res.headers["strict-transport-security"]).toContain("max-age="); + // Only /docs gets the relaxed policy; API responses stay strict. + expect(res.headers["content-security-policy"]).not.toContain("script-src 'self' 'unsafe-inline'"); }); it("emits a CSP that lets the Swagger UI assets and inline bootstrap load", async () => { diff --git a/src/middleware/security.ts b/src/middleware/security.ts index 89b8e26..3ee6a2f 100644 --- a/src/middleware/security.ts +++ b/src/middleware/security.ts @@ -13,27 +13,33 @@ const SWAGGER_CDN = "https://unpkg.com"; // X-Content-Type-Options, X-Frame-Options, Referrer-Policy, etc. We only // override the Content-Security-Policy so that the JSON API stays strict while // the Swagger UI at /docs continues to load. -export function securityHeaders(): RequestHandler { +function buildHelmet(relaxed: boolean): RequestHandler { return helmet({ - contentSecurityPolicy: { - useDefaults: true, - directives: { - defaultSrc: ["'self'"], - // The Swagger bundle comes from unpkg; its init block is inline. - scriptSrc: ["'self'", "'unsafe-inline'", SWAGGER_CDN], - // Swagger injects inline styles and pulls its stylesheet from unpkg. - styleSrc: ["'self'", "'unsafe-inline'", SWAGGER_CDN], - // Swagger UI renders icons as data: URIs. - imgSrc: ["'self'", "data:", SWAGGER_CDN], - // The UI fetches /openapi.json from the same origin. - connectSrc: ["'self'"], - fontSrc: ["'self'", "data:", SWAGGER_CDN], - objectSrc: ["'none'"], - frameAncestors: ["'none'"], - baseUri: ["'self'"], - upgradeInsecureRequests: null, - }, - }, + contentSecurityPolicy: relaxed + ? { + useDefaults: true, + directives: { + defaultSrc: ["'self'"], + // The Swagger bundle comes from unpkg; its init block is inline. + scriptSrc: ["'self'", "'unsafe-inline'", SWAGGER_CDN], + // Swagger injects inline styles and pulls its stylesheet from unpkg. + styleSrc: ["'self'", "'unsafe-inline'", SWAGGER_CDN], + // Swagger UI renders icons as data: URIs. + imgSrc: ["'self'", "data:", SWAGGER_CDN], + // The UI fetches /openapi.json from the same origin. + connectSrc: ["'self'"], + fontSrc: ["'self'", "data:", SWAGGER_CDN], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + baseUri: ["'self'"], + upgradeInsecureRequests: null, + }, + } + : { + // Strict Helmet default policy (no 'unsafe-inline' for scripts). + useDefaults: true, + directives: { upgradeInsecureRequests: null }, + }, // The API serves JSON and downloadable resources to third-party clients; // a strict same-origin embedder policy would break those cross-origin reads. crossOriginEmbedderPolicy: false, @@ -41,3 +47,12 @@ export function securityHeaders(): RequestHandler { crossOriginResourcePolicy: { policy: "cross-origin" }, }); } + +export function securityHeaders(): RequestHandler { + const strict = buildHelmet(false); + const relaxed = buildHelmet(true); + return (req, res, next) => { + const isDocs = req.path === "/docs" || req.path.startsWith("/docs/"); + return (isDocs ? relaxed : strict)(req, res, next); + }; +}