Files
WRNexusJS/packages/uploader/src/security.ts
T
2026-07-27 12:42:18 +05:30

209 lines
6.4 KiB
TypeScript

import { accepts, contentTypeOf, extOf } from "./mime.ts";
export interface UploadPolicy {
maxBytes?: number;
accept?: string[];
requireChecksum?: boolean;
filenamePattern?: RegExp;
}
export interface UploadInspection {
filename: string;
contentType: string;
size: number;
sha256: string;
extension: string;
}
export class UploadPolicyError extends Error {
constructor(
message: string,
readonly code: string,
) {
super(message);
this.name = "UploadPolicyError";
}
}
function safePrefix(prefix: string): string {
const segments = prefix
.split(/[\\/]+/)
.map((segment) => segment.trim())
.filter((segment) => segment && segment !== "." && segment !== "..")
.map((segment) => segment.replace(/[^A-Za-z0-9._-]+/g, "-"))
.filter(Boolean);
return segments.join("/") || "uploads";
}
export function safeObjectKey(filename: string, prefix = "uploads"): string {
const extension = extOf(filename)
.replace(/[^A-Za-z0-9.]/g, "")
.slice(0, 16);
const date = new Date().toISOString().slice(0, 10);
return `${safePrefix(prefix)}/${date}/${crypto.randomUUID()}${extension}`;
}
export async function inspectUpload(
filename: string,
bytes: Uint8Array,
declaredType?: string,
): Promise<UploadInspection> {
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
const sha256 = [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
return {
filename,
contentType: declaredType || contentTypeOf(filename),
size: bytes.byteLength,
sha256,
extension: extOf(filename),
};
}
export function enforceUploadPolicy(
inspection: UploadInspection,
policy: UploadPolicy,
expectedChecksum?: string,
): void {
if (policy.maxBytes !== undefined) {
if (!Number.isFinite(policy.maxBytes) || policy.maxBytes < 0) {
throw new TypeError("Upload maxBytes must be a non-negative number");
}
if (inspection.size > policy.maxBytes) {
throw new UploadPolicyError(`File exceeds ${policy.maxBytes} bytes`, "UPLOAD_TOO_LARGE");
}
}
if (
policy.accept?.length &&
!accepts(policy.accept, {
name: inspection.filename,
type: inspection.contentType,
})
) {
throw new UploadPolicyError("File type is not allowed", "UPLOAD_TYPE_REJECTED");
}
if (policy.filenamePattern && !policy.filenamePattern.test(inspection.filename)) {
throw new UploadPolicyError("Filename is not allowed", "UPLOAD_FILENAME_REJECTED");
}
if (policy.requireChecksum && !expectedChecksum) {
throw new UploadPolicyError("Checksum is required", "UPLOAD_CHECKSUM_REQUIRED");
}
if (expectedChecksum && expectedChecksum.toLowerCase() !== inspection.sha256) {
throw new UploadPolicyError("Checksum does not match", "UPLOAD_CHECKSUM_MISMATCH");
}
}
function signature(bytes: Uint8Array, values: number[]): boolean {
return values.every((value, index) => bytes[index] === value);
}
export function sniffContentType(bytes: Uint8Array): string | null {
if (signature(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return "image/png";
}
if (signature(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
if (signature(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif";
if (signature(bytes, [0x25, 0x50, 0x44, 0x46])) return "application/pdf";
if (signature(bytes, [0x50, 0x4b, 0x03, 0x04])) return "application/zip";
if (
signature(bytes, [0x52, 0x49, 0x46, 0x46]) &&
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP"
) {
return "image/webp";
}
return null;
}
export interface SignedFileToken {
store: string;
key: string;
expiresAt: number;
disposition?: "inline" | "attachment";
}
function base64url(value: Uint8Array): string {
let text = "";
for (const byte of value) text += String.fromCharCode(byte);
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function assertSigningSecret(secret: string): void {
if (secret.length < 16) {
throw new TypeError("Signed file token secret must be at least 16 characters");
}
}
function assertSignedFileToken(value: SignedFileToken): void {
if (!value.store.trim()) throw new TypeError("Signed file token store is required");
if (!value.key.trim()) throw new TypeError("Signed file token key is required");
if (!Number.isFinite(value.expiresAt)) {
throw new TypeError("Signed file token expiresAt must be finite");
}
if (
value.disposition !== undefined &&
value.disposition !== "inline" &&
value.disposition !== "attachment"
) {
throw new TypeError("Signed file token disposition is invalid");
}
}
async function sign(value: string, secret: string): Promise<string> {
assertSigningSecret(secret);
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return base64url(
new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))),
);
}
function constantTimeEqual(left: string, right: string): boolean {
const length = Math.max(left.length, right.length);
let difference = left.length ^ right.length;
for (let index = 0; index < length; index++) {
difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
}
return difference === 0;
}
export async function createSignedFileToken(
input: SignedFileToken,
secret: string,
): Promise<string> {
assertSignedFileToken(input);
const payload = base64url(new TextEncoder().encode(JSON.stringify(input)));
return `${payload}.${await sign(payload, secret)}`;
}
export async function verifySignedFileToken(
token: string,
secret: string,
now = Date.now(),
): Promise<SignedFileToken | null> {
const parts = token.split(".");
if (parts.length !== 2) return null;
const [payload, signature] = parts;
if (!payload || !signature) return null;
const expected = await sign(payload, secret);
if (!constantTimeEqual(expected, signature)) return null;
try {
const padding = payload.length % 4 ? "=".repeat(4 - (payload.length % 4)) : "";
const raw = atob(payload.replace(/-/g, "+").replace(/_/g, "/") + padding);
const value = JSON.parse(
new TextDecoder().decode(Uint8Array.from(raw, (character) => character.charCodeAt(0))),
) as SignedFileToken;
assertSignedFileToken(value);
return value.expiresAt > now ? value : null;
} catch {
return null;
}
}