@devultur/self-host 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -167,7 +167,22 @@ interface WorkerConfig {
167
167
  */
168
168
  lock?: ObjectLock;
169
169
  transcode?: TranscodeConfig;
170
+ /**
171
+ * What an upload whose first bytes contradict its declared Content-Type gets (see `ContentSniffingMode`). Every
172
+ * mismatch is logged (`[content-sniff] {declared, detected, key, action}`). A transcode's outputs are never checked.
173
+ * Unset: `scriptable`.
174
+ */
175
+ contentSniffing?: ContentSniffingMode;
170
176
  }
177
+ /**
178
+ * - `scriptable` (default): `415 content_type_mismatch` only when the bytes are markup (HTML, SVG, XML) under an inert
179
+ * type; any other mismatch is stored with its declared type.
180
+ * - `reject`: `415` for every mismatch.
181
+ * - `octet-stream`: a single PUT that mismatches is stored as `application/octet-stream`; a multipart upload is
182
+ * rejected (R2 fixes its type when it starts).
183
+ * - `off`: no check.
184
+ */
185
+ type ContentSniffingMode = "scriptable" | "reject" | "octet-stream" | "off";
171
186
  /** What an auth plugin established about the request's credential. */
172
187
  interface RequestAccess {
173
188
  /**
@@ -752,4 +767,4 @@ interface CreateWorkerOptions {
752
767
  }
753
768
  declare function createWorker(options?: CreateWorkerOptions): Hono<WorkerApp, hono_types.BlankSchema, "/">;
754
769
 
755
- export { type AllowedCredential, type AssetListEntry, type AuthorizeLockChangeHook, type BeforeLinkRedeemHook, type ComposedPlugins, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, ENCRYPTION_KEY_MISSING_CODE, type EncodeMetrics, HOLD_PAGE_SIZE, type HoldSkip, type ImageTransformParams, type IssueCredentialHook, type Json, KEYS_BUCKET_MISSING_CODE, LOCK_AUDIT_PREFIX, LOCK_HOLD_HEADER, LOCK_METADATA_KEY, LOCK_SIDECAR_PREFIX, LOCK_UNTIL_HEADER, type LockAuditAction, type LockAuditRecord, type LockChangeAction, type LockChangeDecision, type LockChangeRequest, type LockLookupOptions, type LockSidecar, type LockSource, MAX_ASSET_LIST_LIMIT, MAX_EXPIRED_LOOKUPS, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type OnLockAuditHook, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, type PrefixDeleteOptions, type PrefixDeleteResult, type PrefixWalkOptions, RESERVED_LOCK_PREFIXES, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RetainedObject, type RetainedSummary, type RouteResult, type StoredObjectLock, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, deletePrefixRespectingLocks, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readObjectLock, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, retainedSummary, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };
770
+ export { type AllowedCredential, type AssetListEntry, type AuthorizeLockChangeHook, type BeforeLinkRedeemHook, type ComposedPlugins, type ContentSniffingMode, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, ENCRYPTION_KEY_MISSING_CODE, type EncodeMetrics, HOLD_PAGE_SIZE, type HoldSkip, type ImageTransformParams, type IssueCredentialHook, type Json, KEYS_BUCKET_MISSING_CODE, LOCK_AUDIT_PREFIX, LOCK_HOLD_HEADER, LOCK_METADATA_KEY, LOCK_SIDECAR_PREFIX, LOCK_UNTIL_HEADER, type LockAuditAction, type LockAuditRecord, type LockChangeAction, type LockChangeDecision, type LockChangeRequest, type LockLookupOptions, type LockSidecar, type LockSource, MAX_ASSET_LIST_LIMIT, MAX_EXPIRED_LOOKUPS, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type OnLockAuditHook, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, type PrefixDeleteOptions, type PrefixDeleteResult, type PrefixWalkOptions, RESERVED_LOCK_PREFIXES, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RetainedObject, type RetainedSummary, type RouteResult, type StoredObjectLock, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, deletePrefixRespectingLocks, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readObjectLock, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, retainedSummary, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };
package/dist/index.js CHANGED
@@ -801,6 +801,62 @@ function addRouteResult(context, result) {
801
801
  context.set(ROUTE_RESULT_VARIABLE, { ...getRouteResult(context), ...result });
802
802
  }
803
803
 
804
+ // src/routes/content-safety.ts
805
+ var SANDBOX_CSP = "sandbox; default-src 'none'";
806
+ var SAFE_INLINE_TYPES = /* @__PURE__ */ new Set([
807
+ "image/png",
808
+ "image/jpeg",
809
+ "image/webp",
810
+ "image/gif",
811
+ "image/avif",
812
+ "application/pdf",
813
+ "text/plain",
814
+ "text/csv",
815
+ "application/json",
816
+ "text/vtt",
817
+ "application/vnd.apple.mpegurl",
818
+ "application/x-mpegurl"
819
+ ]);
820
+ var SAFE_INLINE_FAMILIES = ["video/", "audio/"];
821
+ function contentTypeEssence(contentType) {
822
+ return contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
823
+ }
824
+ function isSafeInlineType(contentType) {
825
+ const essence = contentTypeEssence(contentType);
826
+ if (SAFE_INLINE_TYPES.has(essence)) return true;
827
+ return SAFE_INLINE_FAMILIES.some((family) => essence.startsWith(family));
828
+ }
829
+ function attachmentOf(disposition, filename) {
830
+ if (disposition?.startsWith("attachment")) return disposition;
831
+ if (disposition?.startsWith("inline")) return `attachment${disposition.slice("inline".length)}`;
832
+ return contentDispositionAttachment(filename || "download");
833
+ }
834
+ function applyContentSafety(headers, filename, isPublic) {
835
+ headers.set("X-Content-Type-Options", "nosniff");
836
+ if (isPublic) headers.set("Cross-Origin-Resource-Policy", "cross-origin");
837
+ if (isSafeInlineType(headers.get("Content-Type"))) return;
838
+ headers.set("Content-Security-Policy", SANDBOX_CSP);
839
+ headers.set("Content-Disposition", attachmentOf(headers.get("Content-Disposition"), filename));
840
+ }
841
+ function basenameOf(url) {
842
+ const last = new URL(url).pathname.split("/").pop() ?? "";
843
+ try {
844
+ return decodeURIComponent(last);
845
+ } catch {
846
+ return last;
847
+ }
848
+ }
849
+ function isPublicMediaUrl(url) {
850
+ return new URL(url).pathname.startsWith("/v1/media/public/");
851
+ }
852
+ var contentSafety = async (context, next) => {
853
+ await next();
854
+ const headers = new Headers(context.res.headers);
855
+ applyContentSafety(headers, basenameOf(context.req.url), isPublicMediaUrl(context.req.url));
856
+ const { body, status, statusText } = context.res;
857
+ context.res = new Response(body, { status, statusText, headers });
858
+ };
859
+
804
860
  // src/routes/object-response.ts
805
861
  import {
806
862
  AES_BLOCK_BYTES,
@@ -865,6 +921,25 @@ async function digestBody(body, declared, header) {
865
921
  if (expected && expected !== actual) return { mismatch: { expected, actual } };
866
922
  return { sha256: actual };
867
923
  }
924
+ async function checkPartSha256(context, multipart, body) {
925
+ const partSha256 = headerSha256(context);
926
+ if (partSha256 === null) {
927
+ return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
928
+ }
929
+ if (multipart.sha256 && !partSha256) {
930
+ return context.json(
931
+ {
932
+ error: "Each part must carry its x-devultur-sha256 when the upload declares one",
933
+ code: "part_sha256_required"
934
+ },
935
+ 400
936
+ );
937
+ }
938
+ if (!partSha256) return null;
939
+ const digest = await digestBody(body, void 0, partSha256);
940
+ if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
941
+ return null;
942
+ }
868
943
  function lockMetadata(lock) {
869
944
  const encoded = encodeLockMetadata(lock);
870
945
  return encoded ? { [LOCK_METADATA_KEY]: encoded } : {};
@@ -1053,6 +1128,7 @@ async function gateRedemption(context, stub) {
1053
1128
  return gateLinkRedemption(context, linkInfoOf(await peeked.json()));
1054
1129
  }
1055
1130
  var download = new Hono2();
1131
+ download.use("*", contentSafety);
1056
1132
  download.get("/:linkId", async (context) => {
1057
1133
  if (context.req.method === "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET" } });
1058
1134
  const linkId = context.req.param("linkId");
@@ -1256,6 +1332,14 @@ function inferMediaType(contentType) {
1256
1332
  if (contentType.startsWith("application/")) return MediaType.FILE;
1257
1333
  return null;
1258
1334
  }
1335
+ function resolveUploadMediaType(declared, contentType, isAssetRequest) {
1336
+ const inferredType = inferMediaType(contentType);
1337
+ if (declared && inferredType && declared !== inferredType) {
1338
+ return failure(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
1339
+ }
1340
+ const mediaType = declared ?? inferredType ?? (isAssetRequest ? MediaType.FILE : null);
1341
+ return mediaType ?? failure(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
1342
+ }
1259
1343
 
1260
1344
  // src/routes/token-upload.ts
1261
1345
  var UPLOAD_TOKEN_TYPE = "upload";
@@ -2508,8 +2592,8 @@ function readJpeg(bytes) {
2508
2592
  return null;
2509
2593
  }
2510
2594
  function readPng(bytes) {
2511
- const isPng = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
2512
- if (!isPng || bytes.length < 24) return null;
2595
+ const isPng2 = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
2596
+ if (!isPng2 || bytes.length < 24) return null;
2513
2597
  return { width: uint32BE(bytes, 16), height: uint32BE(bytes, 20) };
2514
2598
  }
2515
2599
  function readWebp(bytes) {
@@ -2920,6 +3004,7 @@ function downloadDisposition(storedDisposition, key, filename) {
2920
3004
  return contentDispositionAttachment(key.split("/").pop() || "download");
2921
3005
  }
2922
3006
  var media = new Hono7();
3007
+ media.use("*", contentSafety);
2923
3008
  media.get("/:key{.+}", async (context) => {
2924
3009
  const key = context.req.param("key");
2925
3010
  const fullKey = storageKey(context, key);
@@ -3042,6 +3127,7 @@ media.get("/:key{.+}", async (context) => {
3042
3127
  headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
3043
3128
  }
3044
3129
  if (isOwnerAccess) for (const [name, value] of await lockHeaders(context, key, object)) headers.set(name, value);
3130
+ applyContentSafety(headers, key.split("/").pop() ?? "", !isPrivate);
3045
3131
  const { response, kind } = await streamObject({
3046
3132
  bucket: context.env.MEDIA_BUCKET,
3047
3133
  keysBucket: context.env.KEYS_BUCKET,
@@ -4369,6 +4455,17 @@ var DERIVED_CONTENT_TYPES = /* @__PURE__ */ new Set([
4369
4455
  function isDerivedAssetKey(key) {
4370
4456
  return DERIVED_ASSET_KEY.test(key);
4371
4457
  }
4458
+ var DERIVED_TYPES_BY_FOLDER = {
4459
+ hls: /* @__PURE__ */ new Set(["application/vnd.apple.mpegurl", "video/mp2t"]),
4460
+ mp4: /* @__PURE__ */ new Set(["video/mp4"]),
4461
+ thumbnails: /* @__PURE__ */ new Set(["image/jpeg"]),
4462
+ storyboard: /* @__PURE__ */ new Set(["image/jpeg", "text/vtt"])
4463
+ };
4464
+ var DERIVED_OUTPUT_KEY = /^(?:public|private)\/video\/[A-Za-z0-9-]+\/(hls|mp4|thumbnails|storyboard)\/.+$/;
4465
+ function isTrustedDerivedOutput(key, contentType) {
4466
+ const folder = DERIVED_OUTPUT_KEY.exec(key)?.[1];
4467
+ return folder !== void 0 && (DERIVED_TYPES_BY_FOLDER[folder]?.has(contentType) ?? false);
4468
+ }
4372
4469
  var RAW_LIST_LIMIT = 100;
4373
4470
  async function inheritedExpiresAt(context, key) {
4374
4471
  if (!isDerivedAssetKey(key)) return void 0;
@@ -4391,6 +4488,7 @@ async function derivedAssetPending(context, key) {
4391
4488
  return {
4392
4489
  contentType: DERIVED_CONTENT_TYPES.has(declared) ? declared : "application/octet-stream",
4393
4490
  storageKey: storageKey(context, key),
4491
+ isDerived: true,
4394
4492
  ...security ? { security } : {},
4395
4493
  ...expiresAt ? { expiresAt } : {}
4396
4494
  };
@@ -4457,12 +4555,298 @@ async function afterUploadWrite(context, key, pending, write, writtenEnc) {
4457
4555
  });
4458
4556
  }
4459
4557
 
4558
+ // src/routes/upload-content-check.ts
4559
+ import { decodeEncryptionMeta as decodeEncryptionMeta9 } from "@devultur/core";
4560
+
4561
+ // src/markup-sniff.ts
4562
+ var MARKUP_WINDOW_BYTES = 1024;
4563
+ function utf16Encoding(head) {
4564
+ if (head[0] === 255 && head[1] === 254) return "utf-16le";
4565
+ if (head[0] === 254 && head[1] === 255) return "utf-16be";
4566
+ return null;
4567
+ }
4568
+ function isBinaryDataByte(byte) {
4569
+ return byte <= 8 || byte === 11 || byte >= 14 && byte <= 26 || byte >= 28 && byte <= 31;
4570
+ }
4571
+ function decodesStrictly(head, encoding) {
4572
+ try {
4573
+ new TextDecoder(encoding, { fatal: true }).decode(head, { stream: true });
4574
+ return true;
4575
+ } catch {
4576
+ return false;
4577
+ }
4578
+ }
4579
+ function leadingText(head) {
4580
+ const window = head.subarray(0, MARKUP_WINDOW_BYTES);
4581
+ const decoded = new TextDecoder(utf16Encoding(head) ?? "utf-8").decode(window, { stream: true });
4582
+ return decoded.replace(/^/, "").trimStart();
4583
+ }
4584
+ var HTML_TAGS = ["html", "head", "script", "iframe", "h1", "div", "font", "table", "a", "style", "title", "b"];
4585
+ var MORE_HTML_TAGS = ["body", "br", "p"];
4586
+ var DISTINCT_TAGS = ["html", "head", "script", "iframe", "div", "font", "table", "style", "title", "body"];
4587
+ var tagSource = (tags) => `<(?:${tags.join("|")})[\\s>]`;
4588
+ var ANY_HTML_TAG = new RegExp(tagSource([...HTML_TAGS, ...MORE_HTML_TAGS]));
4589
+ var DISTINCT_HTML_TAG = new RegExp(tagSource(DISTINCT_TAGS));
4590
+ var SVG_TAG = /<svg[\s>/]/;
4591
+ var LEADING_HTML = new RegExp(`^(?:<!doctype html|<!--|${tagSource([...HTML_TAGS, ...MORE_HTML_TAGS])})`);
4592
+ function detectScriptable(head) {
4593
+ const start = leadingText(head).slice(0, 512).toLowerCase();
4594
+ if (start.startsWith("<?xml")) return SVG_TAG.test(start) ? "svg" : "xml";
4595
+ if (/^<svg[\s>/]/.test(start)) return "svg";
4596
+ if (LEADING_HTML.test(start)) return "html";
4597
+ return null;
4598
+ }
4599
+ function windowText(head, end) {
4600
+ const window = head.subarray(0, Math.min(end, MARKUP_WINDOW_BYTES));
4601
+ const utf16 = utf16Encoding(head);
4602
+ const text = utf16 ? new TextDecoder(utf16).decode(window, { stream: true }) : String.fromCharCode(...window);
4603
+ return text.toLowerCase();
4604
+ }
4605
+ function markupIn(text, htmlTag) {
4606
+ if (SVG_TAG.test(text)) return "svg";
4607
+ if (text.includes("<!doctype html") || htmlTag.test(text)) return "html";
4608
+ return null;
4609
+ }
4610
+ function detectEmbeddedMarkup(head) {
4611
+ return markupIn(windowText(head, MARKUP_WINDOW_BYTES), DISTINCT_HTML_TAG);
4612
+ }
4613
+ function detectMarkupBefore(head, end) {
4614
+ const text = windowText(head, end);
4615
+ if (text.includes("<!--")) return "html";
4616
+ return markupIn(text, ANY_HTML_TAG);
4617
+ }
4618
+
4619
+ // src/content-sniff.ts
4620
+ var SNIFF_HEAD_BYTES = 4096;
4621
+ var ascii = (text) => Array.from(text, (char) => char.charCodeAt(0));
4622
+ function hasBytesAt(head, offset, bytes) {
4623
+ if (head.length < offset + bytes.length) return false;
4624
+ return bytes.every((byte, index) => head[offset + index] === byte);
4625
+ }
4626
+ function readAscii(head, start, end) {
4627
+ return String.fromCharCode(...head.subarray(start, Math.min(end, head.length)));
4628
+ }
4629
+ var startsWith = (...bytes) => (head) => hasBytesAt(head, 0, bytes);
4630
+ var riff = (form) => (head) => hasBytesAt(head, 0, ascii("RIFF")) && hasBytesAt(head, 8, ascii(form));
4631
+ var PDF_HEADER = ascii("%PDF-");
4632
+ function pdfHeaderOffset(head) {
4633
+ const searchEnd = Math.min(head.length, MARKUP_WINDOW_BYTES) - PDF_HEADER.length;
4634
+ for (let offset = 0; offset <= searchEnd; offset++) {
4635
+ if (hasBytesAt(head, offset, PDF_HEADER)) return offset;
4636
+ }
4637
+ return -1;
4638
+ }
4639
+ var isPdf = (head) => pdfHeaderOffset(head) >= 0;
4640
+ function ftypBrands(head) {
4641
+ if (!hasBytesAt(head, 4, ascii("ftyp"))) return null;
4642
+ const boxSize = (head[0] ?? 0) << 24 | (head[1] ?? 0) << 16 | (head[2] ?? 0) << 8 | (head[3] ?? 0);
4643
+ const boxEnd = Math.min(boxSize >= 16 ? boxSize : 16, head.length);
4644
+ const brands = [readAscii(head, 8, 12)];
4645
+ for (let offset = 16; offset + 4 <= boxEnd; offset += 4) brands.push(readAscii(head, offset, offset + 4));
4646
+ return brands;
4647
+ }
4648
+ var AVIF_BRANDS = /* @__PURE__ */ new Set(["avif", "avis"]);
4649
+ var HEIF_BRANDS = /* @__PURE__ */ new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "mif1", "msf1"]);
4650
+ var LEGACY_MOV_ATOMS = ["moov", "mdat", "wide", "free", "skip", "pnot"];
4651
+ var hasBrand = (brands) => (head) => ftypBrands(head)?.some((brand) => brands.has(brand)) ?? false;
4652
+ var isIsoMedia = (head) => {
4653
+ const brands = ftypBrands(head);
4654
+ if (!brands) return LEGACY_MOV_ATOMS.some((atom) => hasBytesAt(head, 4, ascii(atom)));
4655
+ const majorBrand = brands[0] ?? "";
4656
+ return !AVIF_BRANDS.has(majorBrand) && !HEIF_BRANDS.has(majorBrand);
4657
+ };
4658
+ var EBML_MAGIC = [26, 69, 223, 163];
4659
+ var ebmlDocType = (...docTypes) => (head) => {
4660
+ if (!hasBytesAt(head, 0, EBML_MAGIC)) return false;
4661
+ const header = readAscii(head, 4, 64);
4662
+ return docTypes.some((docType) => header.includes(docType));
4663
+ };
4664
+ var isPng = startsWith(137, 80, 78, 71, 13, 10, 26, 10);
4665
+ var isJpeg = startsWith(255, 216, 255);
4666
+ var isGif = (head) => hasBytesAt(head, 0, ascii("GIF87a")) || hasBytesAt(head, 0, ascii("GIF89a"));
4667
+ var isWebp = riff("WEBP");
4668
+ var isWav = riff("WAVE");
4669
+ var isAvif = hasBrand(AVIF_BRANDS);
4670
+ var isHeif = hasBrand(HEIF_BRANDS);
4671
+ var isWebm = ebmlDocType("webm");
4672
+ var isMatroskaOrWebm = ebmlDocType("matroska", "webm");
4673
+ var isOgg = startsWith(...ascii("OggS"), 0);
4674
+ var isFlac = startsWith(...ascii("fLaC"));
4675
+ var isMp3 = (head) => hasBytesAt(head, 0, ascii("ID3")) || head[0] === 255 && ((head[1] ?? 0) & 224) === 224;
4676
+ var isZip = (head) => hasBytesAt(head, 0, [80, 75, 3, 4]) || hasBytesAt(head, 0, [80, 75, 5, 6]);
4677
+ var isText = (head) => {
4678
+ const utf16 = utf16Encoding(head);
4679
+ if (utf16) return decodesStrictly(head.subarray(2), utf16);
4680
+ return !head.some(isBinaryDataByte);
4681
+ };
4682
+ var isJson = (head) => !head.some(isBinaryDataByte) && decodesStrictly(head, "utf-8") && /^[{[]/.test(leadingText(head));
4683
+ var FAMILIES = [
4684
+ ["pdf", startsWith(...PDF_HEADER)],
4685
+ ["png", isPng],
4686
+ ["jpeg", isJpeg],
4687
+ ["gif", isGif],
4688
+ ["webp", isWebp],
4689
+ ["wav", isWav],
4690
+ ["avif", isAvif],
4691
+ ["heif", isHeif],
4692
+ ["mp4", isIsoMedia],
4693
+ ["webm", isWebm],
4694
+ ["matroska", isMatroskaOrWebm],
4695
+ ["ogg", isOgg],
4696
+ ["flac", isFlac],
4697
+ ["zip", isZip],
4698
+ ["mp3", isMp3],
4699
+ ["json", isJson],
4700
+ ["text", isText],
4701
+ ["pdf", isPdf]
4702
+ ];
4703
+ function detectFamily(head) {
4704
+ if (head.length === 0) return "empty";
4705
+ const scriptable = detectScriptable(head);
4706
+ if (scriptable) return scriptable;
4707
+ return FAMILIES.find(([, signature]) => signature(head))?.[0] ?? "binary";
4708
+ }
4709
+ var OFFICE_ZIP_TYPES = ["wordprocessingml.document", "spreadsheetml.sheet", "presentationml.presentation"].map(
4710
+ (subtype) => `application/vnd.openxmlformats-officedocument.${subtype}`
4711
+ );
4712
+ var typesWith = (types, signature) => types.map((type) => [type, signature]);
4713
+ var SIGNATURES = new Map([
4714
+ ["application/pdf", isPdf],
4715
+ ["image/png", isPng],
4716
+ ["image/jpeg", isJpeg],
4717
+ ["image/gif", isGif],
4718
+ ["image/webp", isWebp],
4719
+ ["image/avif", isAvif],
4720
+ ...typesWith(["image/heic", "image/heif"], isHeif),
4721
+ ...typesWith(["video/mp4", "video/quicktime", "video/x-m4v", "video/3gpp", "audio/mp4", "audio/x-m4a"], isIsoMedia),
4722
+ ...typesWith(["video/webm", "audio/webm"], isWebm),
4723
+ ...typesWith(["video/x-matroska", "audio/x-matroska"], isMatroskaOrWebm),
4724
+ ...typesWith(["audio/mpeg", "audio/mp3"], isMp3),
4725
+ ...typesWith(["audio/ogg", "video/ogg", "application/ogg", "audio/opus"], isOgg),
4726
+ ...typesWith(["audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave"], isWav),
4727
+ ...typesWith(["audio/flac", "audio/x-flac"], isFlac),
4728
+ ...typesWith(["application/zip", "application/x-zip-compressed", ...OFFICE_ZIP_TYPES], isZip),
4729
+ ...typesWith(["text/plain", "text/csv", "text/markdown", "text/tab-separated-values"], isText),
4730
+ ["application/json", isJson]
4731
+ ]);
4732
+ function essenceOf(contentType) {
4733
+ return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
4734
+ }
4735
+ var TEXT_TYPES = /* @__PURE__ */ new Set([
4736
+ "text/plain",
4737
+ "text/csv",
4738
+ "text/markdown",
4739
+ "text/tab-separated-values",
4740
+ "application/json"
4741
+ ]);
4742
+ function embeddedMarkup(essence, head) {
4743
+ if (essence === "application/pdf") {
4744
+ const headerOffset = pdfHeaderOffset(head);
4745
+ const before = detectMarkupBefore(head, headerOffset < 0 ? MARKUP_WINDOW_BYTES : headerOffset);
4746
+ return before ?? detectEmbeddedMarkup(head);
4747
+ }
4748
+ return TEXT_TYPES.has(essence) ? detectEmbeddedMarkup(head) : null;
4749
+ }
4750
+ var SCRIPTABLE_FAMILIES = /* @__PURE__ */ new Set(["html", "svg", "xml"]);
4751
+ var mismatch = (detected) => ({
4752
+ verdict: "mismatch",
4753
+ detected,
4754
+ isScriptable: SCRIPTABLE_FAMILIES.has(detected)
4755
+ });
4756
+ function sniffContent(declaredType, head) {
4757
+ const essence = essenceOf(declaredType);
4758
+ const signature = SIGNATURES.get(essence);
4759
+ if (!signature) return { verdict: "unverifiable" };
4760
+ const sample = head.subarray(0, SNIFF_HEAD_BYTES);
4761
+ const scriptable = detectScriptable(sample) ?? embeddedMarkup(essence, sample);
4762
+ if (scriptable) return mismatch(scriptable);
4763
+ if (signature(sample)) return { verdict: "match" };
4764
+ return mismatch(detectFamily(sample));
4765
+ }
4766
+
4767
+ // src/routes/upload-content-check.ts
4768
+ var OCTET_STREAM = "application/octet-stream";
4769
+ var MULTIPART_PENDING_TTL_SECONDS = 24 * 60 * 60;
4770
+ function sniffingModeOf(context, pending) {
4771
+ return pending?.contentSniffing ?? getRequestState(context).config?.contentSniffing ?? "scriptable";
4772
+ }
4773
+ function shouldSniffUpload(context, key, pending, contentType) {
4774
+ const isRunnerWrite = pending ? Boolean(pending.isDerived) : true;
4775
+ if (isRunnerWrite && isTrustedDerivedOutput(key, contentType)) return false;
4776
+ return sniffingModeOf(context, pending) !== "off";
4777
+ }
4778
+ function mismatchAction(mode, mismatch2, isMultipart) {
4779
+ if (mode === "scriptable") return mismatch2.isScriptable ? "rejected" : "accepted";
4780
+ if (mode === "octet-stream" && !isMultipart) return "stored_as_octet_stream";
4781
+ return "rejected";
4782
+ }
4783
+ function logMismatch(declared, mismatch2, key, action) {
4784
+ console.warn(`[content-sniff] ${JSON.stringify({ declared, detected: mismatch2.detected, key, action })}`);
4785
+ }
4786
+ function contentTypeMismatch(context, declared, detected) {
4787
+ return context.json(
4788
+ {
4789
+ error: `The file's content (${detected}) does not match its declared Content-Type ${declared}`,
4790
+ code: "content_type_mismatch",
4791
+ declared,
4792
+ detected
4793
+ },
4794
+ 415
4795
+ );
4796
+ }
4797
+ function sniffHead(declared, body) {
4798
+ return sniffContent(declared, new Uint8Array(body, 0, Math.min(body.byteLength, SNIFF_HEAD_BYTES)));
4799
+ }
4800
+ function checkSinglePutContent(context, upload2) {
4801
+ const { contentType, key, pending } = upload2;
4802
+ if (!shouldSniffUpload(context, key, pending, contentType)) return { contentType };
4803
+ const result = sniffHead(contentType, upload2.body);
4804
+ if (result.verdict !== "mismatch") return { contentType };
4805
+ const action = mismatchAction(sniffingModeOf(context, pending), result, false);
4806
+ logMismatch(contentType, result, key, action);
4807
+ if (action === "accepted") return { contentType };
4808
+ if (action === "stored_as_octet_stream") return { contentType: OCTET_STREAM };
4809
+ return { rejected: contentTypeMismatch(context, contentType, result.detected) };
4810
+ }
4811
+ async function discardMultipart(context, active) {
4812
+ const { pending, multipart, kvKey } = active;
4813
+ await context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId).abort();
4814
+ await context.env.MEDIA_KV.delete(kvKey);
4815
+ const meta = decodeEncryptionMeta9(multipart.enc);
4816
+ if (meta && context.env.KEYS_BUCKET) await context.env.KEYS_BUCKET.delete(`${pending.storageKey}/${meta.keyId}`);
4817
+ }
4818
+ async function checkFirstPart(context, active, key, partNumber, body) {
4819
+ const { pending, multipart } = active;
4820
+ if (partNumber !== 1 || !multipart.checksFirstPart) return null;
4821
+ const result = sniffHead(pending.contentType, body);
4822
+ if (result.verdict !== "mismatch") return null;
4823
+ const action = mismatchAction(sniffingModeOf(context, pending), result, true);
4824
+ logMismatch(pending.contentType, result, key, action);
4825
+ if (action === "accepted") return null;
4826
+ await discardMultipart(context, active);
4827
+ return contentTypeMismatch(context, pending.contentType, result.detected);
4828
+ }
4829
+ async function recordFirstPart(context, active, partNumber, etag) {
4830
+ const { pending, multipart, kvKey } = active;
4831
+ if (partNumber !== 1 || !multipart.checksFirstPart) return;
4832
+ const recorded = { ...pending, multipart: { ...multipart, firstPartEtag: etag } };
4833
+ await context.env.MEDIA_KV.put(kvKey, JSON.stringify(recorded), { expirationTtl: MULTIPART_PENDING_TTL_SECONDS });
4834
+ }
4835
+ function unverifiedFirstPart(context, multipart, parts) {
4836
+ if (!multipart.checksFirstPart) return null;
4837
+ if (multipart.firstPartEtag && parts[0]?.etag === multipart.firstPartEtag) return null;
4838
+ return context.json(
4839
+ { error: "Part 1 of this upload has not been checked yet, or was replaced", code: "content_unverified" },
4840
+ 409
4841
+ );
4842
+ }
4843
+
4460
4844
  // src/routes/upload-multipart.ts
4461
4845
  import {
4462
4846
  AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
4463
4847
  classifyUploadRequest,
4464
4848
  counterForBlock,
4465
- decodeEncryptionMeta as decodeEncryptionMeta9,
4849
+ decodeEncryptionMeta as decodeEncryptionMeta10,
4466
4850
  encryptBytes,
4467
4851
  MULTIPART_PART_SIZE_BYTES,
4468
4852
  MULTIPART_QUERY,
@@ -4568,7 +4952,6 @@ async function cancelPendingUpload(context, uploadUrl) {
4568
4952
  }
4569
4953
 
4570
4954
  // src/routes/upload-multipart.ts
4571
- var MULTIPART_PENDING_TTL_SECONDS = 24 * 60 * 60;
4572
4955
  var MAX_PART_COUNT = 1e4;
4573
4956
  async function readMultipartAt(context, kvKey) {
4574
4957
  const raw = await context.env.MEDIA_KV.get(kvKey);
@@ -4660,7 +5043,9 @@ async function handleMultipartCreate(context, key, target) {
4660
5043
  size,
4661
5044
  partSize,
4662
5045
  ...enc ? { enc } : {},
4663
- ...declaredSha256 ? { sha256: declaredSha256 } : {}
5046
+ ...declaredSha256 ? { sha256: declaredSha256 } : {},
5047
+ // The type is fixed now, before any byte: part 1 is checked against it when it arrives.
5048
+ ...shouldSniffUpload(context, key, pending, pending.contentType) ? { checksFirstPart: true } : {}
4664
5049
  };
4665
5050
  await context.env.MEDIA_KV.put(kvKey, JSON.stringify({ ...pending, multipart }), {
4666
5051
  expirationTtl: MULTIPART_PENDING_TTL_SECONDS
@@ -4674,7 +5059,7 @@ async function handleMultipartCreate(context, key, target) {
4674
5059
  }
4675
5060
  async function encryptPart(context, pending, multipart, partNumber, body) {
4676
5061
  if (!multipart.enc) return body;
4677
- const meta = decodeEncryptionMeta9(multipart.enc);
5062
+ const meta = decodeEncryptionMeta10(multipart.enc);
4678
5063
  const material = meta ? await loadStorageKey(context.env.KEYS_BUCKET, pending.storageKey, meta.keyId) : null;
4679
5064
  if (!meta || !material) return null;
4680
5065
  const startBlock = (partNumber - 1) * multipart.partSize / AES_BLOCK_BYTES2;
@@ -4698,10 +5083,13 @@ async function handleMultipartPart(context) {
4698
5083
  }
4699
5084
  const partChecksumFailure = await checkPartSha256(context, multipart, body);
4700
5085
  if (partChecksumFailure) return partChecksumFailure;
5086
+ const contentFailure = await checkFirstPart(context, active, key, partNumber, body);
5087
+ if (contentFailure) return contentFailure;
4701
5088
  const storedPart = await encryptPart(context, pending, multipart, partNumber, body);
4702
5089
  if (!storedPart) return context.json({ error: "Encryption key not found for this upload" }, 500);
4703
5090
  const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
4704
5091
  const uploaded = await multipartUpload.uploadPart(partNumber, storedPart);
5092
+ await recordFirstPart(context, active, partNumber, uploaded.etag);
4705
5093
  return context.json({ partNumber: uploaded.partNumber, etag: uploaded.etag });
4706
5094
  }
4707
5095
  async function handleMultipartComplete(context, key) {
@@ -4711,6 +5099,8 @@ async function handleMultipartComplete(context, key) {
4711
5099
  const { pending, multipart, kvKey } = active;
4712
5100
  const parts = normalizeCompletedParts(multipart, body?.parts);
4713
5101
  if (!parts) return context.json({ error: "parts must list every part exactly once", code: "invalid_parts" }, 400);
5102
+ const unverified = unverifiedFirstPart(context, multipart, parts);
5103
+ if (unverified) return unverified;
4714
5104
  const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
4715
5105
  if ("blocked" in write) return write.blocked;
4716
5106
  const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
@@ -4725,25 +5115,6 @@ async function handleMultipartComplete(context, key) {
4725
5115
  const integrity = multipart.sha256 ? { sha256: multipart.sha256, sha256Verified: false } : {};
4726
5116
  return context.json({ key, size: object.size, ...integrity });
4727
5117
  }
4728
- async function checkPartSha256(context, multipart, body) {
4729
- const partSha256 = headerSha256(context);
4730
- if (partSha256 === null) {
4731
- return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
4732
- }
4733
- if (multipart.sha256 && !partSha256) {
4734
- return context.json(
4735
- {
4736
- error: "Each part must carry its x-devultur-sha256 when the upload declares one",
4737
- code: "part_sha256_required"
4738
- },
4739
- 400
4740
- );
4741
- }
4742
- if (!partSha256) return null;
4743
- const digest = await digestBody(body, void 0, partSha256);
4744
- if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
4745
- return null;
4746
- }
4747
5118
  async function handleMultipartPost(context) {
4748
5119
  const key = context.req.param("key") ?? "";
4749
5120
  const kind = uploadRequestKindOf(context);
@@ -4777,20 +5148,8 @@ upload.post("/", async (context) => {
4777
5148
  if (!filename || !body.contentType) {
4778
5149
  return context.json({ error: "filename and contentType are required" }, 400);
4779
5150
  }
4780
- const inferredType = inferMediaType(body.contentType);
4781
- if (body.mediaType && inferredType && body.mediaType !== inferredType) {
4782
- return context.json(
4783
- {
4784
- error: `mediaType "${body.mediaType}" does not match content type ${body.contentType}`,
4785
- code: "media_type_mismatch"
4786
- },
4787
- 400
4788
- );
4789
- }
4790
- const mediaType = body.mediaType ?? inferredType ?? (isAssetRequest ? MediaType2.FILE : null);
4791
- if (!mediaType) {
4792
- return context.json({ error: `Unsupported content type: ${body.contentType}` }, 400);
4793
- }
5151
+ const mediaType = resolveUploadMediaType(body.mediaType, body.contentType, isAssetRequest);
5152
+ if (isUploadTargetError(mediaType)) return targetFailure(context, mediaType);
4794
5153
  const scope = getRequestState(context);
4795
5154
  const uploadRule = scope.config?.uploadRules?.[mediaType];
4796
5155
  if (!isContentTypeAllowed(body.contentType, uploadRule?.accept)) {
@@ -4854,7 +5213,8 @@ upload.post("/", async (context) => {
4854
5213
  ...target.isAsset ? { isAsset: true, overwrite: shouldOverwrite } : {},
4855
5214
  ...cacheControl ? { cacheControl } : {},
4856
5215
  ...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {},
4857
- ...lock ? { lock } : {}
5216
+ ...lock ? { lock } : {},
5217
+ ...scope.config?.contentSniffing ? { contentSniffing: scope.config.contentSniffing } : {}
4858
5218
  };
4859
5219
  const pendingId = newUploadPendingId();
4860
5220
  await context.env.MEDIA_KV.put(uploadPendingKvKey(pendingId), JSON.stringify(pending), {
@@ -4885,9 +5245,9 @@ upload.put("/:key{.+}", async (context) => {
4885
5245
  const maxSizeBytes = pending?.maxSizeBytes ?? null;
4886
5246
  const storageClass = pending?.storageClass ?? "Standard";
4887
5247
  const resourceSecurity = pending?.security ?? parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
4888
- const contentType = context.req.header("Content-Type") ?? "application/octet-stream";
4889
- if (expectedContentType && contentType !== expectedContentType) {
4890
- return context.json({ error: `Expected Content-Type ${expectedContentType}, got ${contentType}` }, 400);
5248
+ const declaredContentType = context.req.header("Content-Type") ?? OCTET_STREAM;
5249
+ if (expectedContentType && declaredContentType !== expectedContentType) {
5250
+ return context.json({ error: `Expected Content-Type ${expectedContentType}, got ${declaredContentType}` }, 400);
4891
5251
  }
4892
5252
  const declaredSize = Number(context.req.header("content-length") ?? "0");
4893
5253
  if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
@@ -4908,6 +5268,9 @@ upload.put("/:key{.+}", async (context) => {
4908
5268
  return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
4909
5269
  }
4910
5270
  if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
5271
+ const content = checkSinglePutContent(context, { key, pending, contentType: declaredContentType, body });
5272
+ if ("rejected" in content) return content.rejected;
5273
+ const { contentType } = content;
4911
5274
  const policy = resolveSecurity5(resourceSecurity, getRequestState(context).config?.security);
4912
5275
  const shouldEncrypt = shouldEncryptAtRest(policy, key);
4913
5276
  if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devultur/self-host",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Self-hosted media Worker for Cloudflare: R2 uploads, edge-cached delivery, image transforms, HLS transcode and captions",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -46,7 +46,7 @@
46
46
  "@jsquash/resize": "2.1.1",
47
47
  "@jsquash/webp": "1.5.0",
48
48
  "hono": "^4.7.0",
49
- "@devultur/core": "1.2.0"
49
+ "@devultur/core": "1.3.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@cf-wasm/photon": "0.4.0",