diff --git a/src/lib/contentDisposition.test.ts b/src/lib/contentDisposition.test.ts new file mode 100644 index 0000000..bf0dbc7 --- /dev/null +++ b/src/lib/contentDisposition.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { attachmentDisposition } from "./contentDisposition.js"; + +describe("attachmentDisposition", () => { + it("escapes quotes and semicolons", () => { + const h = attachmentDisposition('a"b;c.pdf'); + expect(h).toBe(`attachment; filename="a_b_c.pdf"; filename*=UTF-8''a%22b%3Bc.pdf`); + }); + it("encodes non-ASCII names", () => { + const h = attachmentDisposition("résumé.pdf"); + expect(h).toContain('filename="r_sum_.pdf"'); + expect(h).toContain("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"); + }); + it("strips CR/LF", () => { + expect(attachmentDisposition("a\r\nb.txt")).not.toMatch(/[\r\n]/); + }); +}); diff --git a/src/lib/contentDisposition.ts b/src/lib/contentDisposition.ts new file mode 100644 index 0000000..9a5f4fb --- /dev/null +++ b/src/lib/contentDisposition.ts @@ -0,0 +1,14 @@ +/** + * Build an RFC 6266 attachment Content-Disposition header (#61): an ASCII + * fallback in `filename` and the percent-encoded UTF-8 name in `filename*`. + */ +export function attachmentDisposition(filename: string): string { + const clean = filename.replace(/[\r\n\u0000-\u001f\u007f]/g, ""); + const fallback = + clean.replace(/[^\x20-\x7e]/g, "_").replace(/["\;%]/g, "_").trim() || "download"; + const encoded = encodeURIComponent(clean || "download").replace( + /['()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} diff --git a/src/lib/priceValidation.test.ts b/src/lib/priceValidation.test.ts new file mode 100644 index 0000000..15a76c7 --- /dev/null +++ b/src/lib/priceValidation.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { priceSchema } from "./priceValidation.js"; + +describe("priceSchema", () => { + it.each(["1", "0.50", "12.1234567", "0.0000001"])("accepts %s", (v) => { + expect(priceSchema.safeParse(v).success).toBe(true); + }); + it.each(["abc", "-1", "0", "0.0", "1.23456789", "", "1e3", " 1", "1."])("rejects %s", (v) => { + expect(priceSchema.safeParse(v).success).toBe(false); + }); +}); diff --git a/src/lib/priceValidation.ts b/src/lib/priceValidation.ts new file mode 100644 index 0000000..455d2e3 --- /dev/null +++ b/src/lib/priceValidation.ts @@ -0,0 +1,11 @@ +import { z } from "zod/v4"; +import { usdcToStroops } from "../utils/usdc.js"; + +/** Zod string for a positive USDC price with at most 7 decimals. */ +export const priceSchema = z.string().refine((v) => { + try { + return usdcToStroops(v) > 0n; + } catch { + return false; + } +}, "must be a positive decimal number with at most 7 decimal places"); diff --git a/src/lib/stellarAddressValidation.test.ts b/src/lib/stellarAddressValidation.test.ts new file mode 100644 index 0000000..d0fa355 --- /dev/null +++ b/src/lib/stellarAddressValidation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { stellarAddressSchema } from "./stellarAddressValidation.js"; + +describe("stellarAddressSchema", () => { + it("accepts a valid address", () => { + expect(stellarAddressSchema.safeParse(Keypair.random().publicKey()).success).toBe(true); + }); + it("rejects malformed values", () => { + expect(stellarAddressSchema.safeParse("G" + "A".repeat(55)).success).toBe(false); + expect(stellarAddressSchema.safeParse("abc").success).toBe(false); + expect(stellarAddressSchema.safeParse("").success).toBe(false); + }); +}); diff --git a/src/lib/stellarAddressValidation.ts b/src/lib/stellarAddressValidation.ts new file mode 100644 index 0000000..7aa52dc --- /dev/null +++ b/src/lib/stellarAddressValidation.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; +import { StrKey } from "@stellar/stellar-sdk"; + +/** Zod string that must be a valid Stellar ed25519 public key (G...). */ +export const stellarAddressSchema = z + .string() + .refine((v) => StrKey.isValidEd25519PublicKey(v), "must be a valid Stellar public key (G..., 56 chars)"); diff --git a/src/lib/textUploadValidation.test.ts b/src/lib/textUploadValidation.test.ts new file mode 100644 index 0000000..4864c77 --- /dev/null +++ b/src/lib/textUploadValidation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { validateTextContent } from "./textUploadValidation.js"; + +describe("validateTextContent", () => { + it("accepts plain text and markdown", () => { + expect(validateTextContent(Buffer.from("# Title\n\nHello 1 < 2 and 3 > 2"))).toBeNull(); + }); + it("rejects HTML", () => { + expect(validateTextContent(Buffer.from("
x"))).not.toBeNull(); + expect(validateTextContent(Buffer.from("hi "))).not.toBeNull(); + }); + it("rejects invalid UTF-8 and NUL bytes", () => { + expect(validateTextContent(Buffer.from([0xff, 0xfe, 0xfd]))).not.toBeNull(); + expect(validateTextContent(Buffer.from("a\u0000b"))).not.toBeNull(); + }); +}); diff --git a/src/lib/textUploadValidation.ts b/src/lib/textUploadValidation.ts new file mode 100644 index 0000000..d597e16 --- /dev/null +++ b/src/lib/textUploadValidation.ts @@ -0,0 +1,28 @@ +/** + * Validation for text/* uploads (#62): content must be valid UTF-8, free of + * NUL bytes, and must not look like HTML/SVG/script markup. + */ + +const MARKUP_PATTERN = + /<\s*(!doctype\s+html|html|head|body|script|iframe|object|embed|svg|style|link|meta|img|form|a\s)\b|<\s*\?xml|javascript:/i; + +export function isTextMimeType(mime: string): boolean { + return mime.startsWith("text/"); +} + +/** Returns an error message when the buffer is not acceptable text, else null. */ +export function validateTextContent(buffer: Buffer): string | null { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + } catch { + return "Text uploads must be valid UTF-8"; + } + if (text.includes("\u0000")) { + return "Text uploads must not contain binary data"; + } + if (MARKUP_PATTERN.test(text)) { + return "Text uploads must not contain HTML or script markup"; + } + return null; +} diff --git a/src/lib/uploadValidation.ts b/src/lib/uploadValidation.ts index be8af3d..31a5889 100644 --- a/src/lib/uploadValidation.ts +++ b/src/lib/uploadValidation.ts @@ -1,3 +1,5 @@ +import { isTextMimeType, validateTextContent } from "./textUploadValidation.js"; + /** * Upload guardrails (#87): content-type allowlist and declared-vs-detected * content-type validation. @@ -150,5 +152,10 @@ export function validateUpload( }; } + if (isTextMimeType(declared)) { + const textError = validateTextContent(buffer); + if (textError) return { ok: false, status: 415, error: textError }; + } + return { ok: true, contentType: declared }; } diff --git a/src/routes/resources.ts b/src/routes/resources.ts index 177c405..a461e81 100644 --- a/src/routes/resources.ts +++ b/src/routes/resources.ts @@ -1,4 +1,5 @@ import { Router, type Router as RouterType } from "express"; +import { attachmentDisposition } from "../lib/contentDisposition.js"; import { promises as fs } from "node:fs"; import { apiKeyAuth } from "../middleware/apiKeyAuth.js"; import { requestSignatureAuth } from "../middleware/requestSignatureAuth.js"; @@ -392,10 +393,8 @@ router.get("/resources/:id", dynamicPaywall, async (req, res) => { const { buffer, mimeType } = fileContent; res.setHeader("Content-Type", mimeType); - res.setHeader( - "Content-Disposition", - `attachment; filename="${resource.storagePath.split("/").pop()}"`, - ); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Disposition", attachmentDisposition(resource.storagePath.split("/").pop() ?? "")); res.send(buffer); }); diff --git a/src/schemas/requests.ts b/src/schemas/requests.ts index d309347..9a53e2d 100644 --- a/src/schemas/requests.ts +++ b/src/schemas/requests.ts @@ -1,12 +1,14 @@ import { z } from "zod/v4"; import { config } from "../config.js"; +import { stellarAddressSchema } from "../lib/stellarAddressValidation.js"; +import { priceSchema } from "../lib/priceValidation.js"; import { webhookEventValues } from "./webhooks.js"; export const publisherRegisterSchema = z .object({ name: z.string().min(1), email: z.email(), - walletAddress: z.string().min(1), + walletAddress: stellarAddressSchema, }) .strict(); @@ -14,8 +16,8 @@ export const linkPublishSchema = z .object({ title: z.string().min(1), description: z.string().optional(), - price: z.string().min(1), - walletAddress: z.string().optional(), + price: priceSchema, + walletAddress: stellarAddressSchema.optional(), externalUrl: z.url(), }) .strict(); @@ -23,9 +25,9 @@ export const linkPublishSchema = z export const filePublishBodySchema = z .object({ title: z.string().min(1), - price: z.string().min(1), + price: priceSchema, description: z.string().optional(), - walletAddress: z.string().optional(), + walletAddress: stellarAddressSchema.optional(), }) .strict(); @@ -44,27 +46,27 @@ export const registerResourceSchema = z export const preparePriceSchema = z .object({ - price: z.string().min(1), + price: priceSchema, }) .strict(); export const setPriceSchema = z .object({ signedXdr: z.string().min(1), - price: z.string().min(1), + price: priceSchema, }) .strict(); export const prepareOwnershipSchema = z .object({ - newCreator: z.string().min(1), + newCreator: stellarAddressSchema, }) .strict(); export const transferOwnershipSchema = z .object({ signedXdr: z.string().min(1), - newCreator: z.string().min(1), + newCreator: stellarAddressSchema, }) .strict();