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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,7 @@ demand) to seed a testnet catalog and verify it end to end.

## License

MIT © 2025 Busiii-adetiba
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).
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions src/lib/externalUrlValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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,<script>1</script>").success).toBe(false);
});
});
14 changes: 14 additions & 0 deletions src/lib/externalUrlValidation.ts
Original file line number Diff line number Diff line change
@@ -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" },
);
2 changes: 2 additions & 0 deletions src/middleware/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
55 changes: 35 additions & 20 deletions src/middleware/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,46 @@ 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,
// Allow Swagger assets and resource downloads to be consumed cross-origin.
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);
};
}
3 changes: 2 additions & 1 deletion src/schemas/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { config } from "../config.js";
import { stellarAddressSchema } from "../lib/stellarAddressValidation.js";
import { priceSchema } from "../lib/priceValidation.js";
import { webhookEventValues } from "./webhooks.js";
import { httpUrlSchema } from "../lib/externalUrlValidation.js";

export const publisherRegisterSchema = z
.object({
Expand All @@ -18,7 +19,7 @@ export const linkPublishSchema = z
description: z.string().optional(),
price: priceSchema,
walletAddress: stellarAddressSchema.optional(),
externalUrl: z.url(),
externalUrl: httpUrlSchema,
})
.strict();

Expand Down