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
17 changes: 17 additions & 0 deletions src/lib/contentDisposition.test.ts
Original file line number Diff line number Diff line change
@@ -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]/);
});
});
14 changes: 14 additions & 0 deletions src/lib/contentDisposition.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
11 changes: 11 additions & 0 deletions src/lib/priceValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 11 additions & 0 deletions src/lib/priceValidation.ts
Original file line number Diff line number Diff line change
@@ -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");
14 changes: 14 additions & 0 deletions src/lib/stellarAddressValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 7 additions & 0 deletions src/lib/stellarAddressValidation.ts
Original file line number Diff line number Diff line change
@@ -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)");
16 changes: 16 additions & 0 deletions src/lib/textUploadValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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("<html><body>x</body></html>"))).not.toBeNull();
expect(validateTextContent(Buffer.from("hi <script>alert(1)</script>"))).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();
});
});
28 changes: 28 additions & 0 deletions src/lib/textUploadValidation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/lib/uploadValidation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isTextMimeType, validateTextContent } from "./textUploadValidation.js";

/**
* Upload guardrails (#87): content-type allowlist and declared-vs-detected
* content-type validation.
Expand Down Expand Up @@ -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 };
}
7 changes: 3 additions & 4 deletions src/routes/resources.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});

Expand Down
20 changes: 11 additions & 9 deletions src/schemas/requests.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
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();

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();

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();

Expand All @@ -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();

Expand Down