partial-content 1.0.0
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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +92 -0
- package/dist/azure.d.ts +79 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +251 -0
- package/dist/azure.js.map +1 -0
- package/dist/content-disposition.d.ts +74 -0
- package/dist/content-disposition.d.ts.map +1 -0
- package/dist/content-disposition.js +253 -0
- package/dist/content-disposition.js.map +1 -0
- package/dist/fs.d.ts +86 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +375 -0
- package/dist/fs.js.map +1 -0
- package/dist/gcs.d.ts +72 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/gcs.js +202 -0
- package/dist/gcs.js.map +1 -0
- package/dist/hono.d.ts +92 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +61 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +281 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +541 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1218 -0
- package/dist/kernel.js.map +1 -0
- package/dist/memory.d.ts +55 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +107 -0
- package/dist/memory.js.map +1 -0
- package/dist/mime.d.ts +49 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +150 -0
- package/dist/mime.js.map +1 -0
- package/dist/node.d.ts +84 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +215 -0
- package/dist/node.js.map +1 -0
- package/dist/object-store.d.ts +472 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +335 -0
- package/dist/object-store.js.map +1 -0
- package/dist/r2.d.ts +94 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +150 -0
- package/dist/r2.js.map +1 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +263 -0
- package/dist/s3.js.map +1 -0
- package/dist/web.d.ts +336 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +1094 -0
- package/dist/web.js.map +1 -0
- package/docs/DESIGN.md +426 -0
- package/package.json +182 -0
- package/src/azure.ts +329 -0
- package/src/content-disposition.ts +300 -0
- package/src/fs.ts +469 -0
- package/src/gcs.ts +290 -0
- package/src/hono.ts +123 -0
- package/src/http.ts +351 -0
- package/src/index.ts +85 -0
- package/src/kernel.ts +1498 -0
- package/src/memory.ts +148 -0
- package/src/mime.ts +160 -0
- package/src/node.ts +261 -0
- package/src/object-store.ts +665 -0
- package/src/r2.ts +232 -0
- package/src/s3.ts +324 -0
- package/src/web.ts +1603 -0
package/SECURITY.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
The latest published minor release receives security fixes.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
Email **oss@nordvec.com** with the details. Please include:
|
|
10
|
+
|
|
11
|
+
- The affected module (`partial-content`, a store adapter, or a framework adapter)
|
|
12
|
+
- A minimal reproduction (a failing request/response pair is ideal)
|
|
13
|
+
- The impact you believe it has (header injection, cache poisoning, path traversal, byte-splicing, DoS)
|
|
14
|
+
|
|
15
|
+
You will receive an acknowledgement within 72 hours. Please do not open a
|
|
16
|
+
public issue for undisclosed vulnerabilities.
|
|
17
|
+
|
|
18
|
+
## Threat model (what this library defends against)
|
|
19
|
+
|
|
20
|
+
`partial-content` sits between attacker-controlled request headers and
|
|
21
|
+
storage-backend metadata. Defenses under test:
|
|
22
|
+
|
|
23
|
+
- **Header injection (CWE-113) and header-write crashes:** every
|
|
24
|
+
metadata-derived or caller-per-request response header value (ETag,
|
|
25
|
+
Last-Modified, Content-Type, digest, Cache-Control, and the signed-URL
|
|
26
|
+
`Location`) is stripped of ALL bytes outside RFC 9110 field-value grammar
|
|
27
|
+
-- not just CRLF, but every control byte that Node `writeHead`, undici
|
|
28
|
+
`Headers`, and Workers reject by throwing, so a poisoned backend value can
|
|
29
|
+
neither inject nor crash the process. ETag values are additionally held to
|
|
30
|
+
`etagc` grammar (interior DQUOTE removed) so a backend hash can never emit
|
|
31
|
+
a structurally-broken entity-tag. Content-Disposition filenames AND the
|
|
32
|
+
disposition type are fully sanitized (controls, bidi overrides, path
|
|
33
|
+
components; the type is coerced to `inline`/`attachment`).
|
|
34
|
+
- **Path traversal / null bytes:** the fs adapter resolves keys against a
|
|
35
|
+
fixed root and rejects `..`, absolute paths (including Windows cross-drive
|
|
36
|
+
`D:\...` keys), and `\0`. On Windows it also rejects reserved device names
|
|
37
|
+
(`NUL`, `CON`, `COM1`, ...) and alternate-data-stream keys (`file::$DATA`),
|
|
38
|
+
neither of which resolves to a file under the root.
|
|
39
|
+
- **Cache poisoning / byte splicing:** strong-validator enforcement for
|
|
40
|
+
`If-Match` and `If-Range`, exact-match If-Range dates, pinned reads with
|
|
41
|
+
one re-validation, and truthful Content-Range derived from the backend's
|
|
42
|
+
actual response.
|
|
43
|
+
- **Range-amplification DoS (CWE-400):** multi-range requests are served as
|
|
44
|
+
`multipart/byteranges`, but overlapping and adjacent ranges are coalesced
|
|
45
|
+
first, and a request whose coalesced parts exceed `maxRanges` (default 50)
|
|
46
|
+
or already cover the whole representation degrades to a single full 200.
|
|
47
|
+
A client cannot force a large or redundant multipart response by sending
|
|
48
|
+
many tiny or overlapping ranges (the mitigation in Go `net/http.ServeContent`
|
|
49
|
+
and nginx `max_ranges`). The multipart `Content-Length` is computed exactly,
|
|
50
|
+
never chunked, so the response size is known and bounded up front.
|
|
51
|
+
- **MIME sniffing / encoding-sniffing XSS:** `X-Content-Type-Options: nosniff`
|
|
52
|
+
on every success + error response (the `200`/`206`/`404`/`502`/`503` bodies
|
|
53
|
+
and the bodyless `412`/`416` denials; `304`/`302` carry none), `charset=utf-8`
|
|
54
|
+
enforcement on textual types, `text/html`
|
|
55
|
+
deliberately absent from the built-in MIME map. Note that `svg` IS in the
|
|
56
|
+
map (`image/svg+xml`) and SVG is active content: served `inline` from your
|
|
57
|
+
own origin it can run same-origin script. For untrusted uploads, serve SVG
|
|
58
|
+
as `attachment` (per-MIME `disposition` hook) or add a sandboxing CSP via
|
|
59
|
+
`securityHeaders`.
|
|
60
|
+
- **Parser robustness:** the range/Content-Range/disposition parsers are
|
|
61
|
+
covered by seeded randomized invariant tests (never throw; bounds always
|
|
62
|
+
valid; output always header-safe).
|
|
63
|
+
- **Transparent-compression corruption:** the http adapter requests
|
|
64
|
+
`Accept-Encoding: identity` and refuses (fails loudly) any response that
|
|
65
|
+
still carries a non-identity `Content-Encoding`, so a decompressed body can
|
|
66
|
+
never be served under compressed-size headers.
|
|
67
|
+
- **Conditional-date strictness:** client-supplied `If-Modified-Since`,
|
|
68
|
+
`If-Unmodified-Since`, and `If-Range` dates are honored only in the three
|
|
69
|
+
RFC 9110 HTTP-date formats; anything else (including ISO 8601) is ignored,
|
|
70
|
+
as the spec mandates.
|
|
71
|
+
|
|
72
|
+
## Server-side request forgery (httpStore)
|
|
73
|
+
|
|
74
|
+
`partial-content/http` fetches whatever your `url(key)` returns; the origin
|
|
75
|
+
is part of YOUR trust boundary:
|
|
76
|
+
|
|
77
|
+
- **Never interpolate untrusted input into `url()` unencoded.** A key like
|
|
78
|
+
`../other-bucket/secret` changes the fetched path. Encode path segments or
|
|
79
|
+
validate keys before they reach the store.
|
|
80
|
+
- **Redirects error by default** (`redirect: "error"`). A serving layer whose
|
|
81
|
+
`url()` may carry untrusted keys must not let a compromised or misconfigured
|
|
82
|
+
origin bounce the store to internal endpoints such as cloud metadata IPs, so
|
|
83
|
+
a 3xx is refused unless you opt in. Object-storage origins answer GET/HEAD
|
|
84
|
+
with a direct 200, so the default costs nothing there. Set `redirect: "follow"`
|
|
85
|
+
for origins that legitimately redirect, and pair it with a validating `fetch`
|
|
86
|
+
when keys are untrusted.
|
|
87
|
+
- **Network-level SSRF defenses are deliberately not built in** (DNS pinning
|
|
88
|
+
and IP allowlists are deployment-specific). Supply them via the `fetch`
|
|
89
|
+
option, which every request goes through.
|
|
90
|
+
|
|
91
|
+
Out of scope: authentication/authorization (gate access before calling the
|
|
92
|
+
handler) and rate limiting (deploy-level concern).
|
package/dist/azure.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Blob Storage ObjectStore adapter for partial-content.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@azure/storage-blob` to implement the {@link ObjectStore} interface.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { BlobServiceClient } from "@azure/storage-blob";
|
|
9
|
+
* import { azureStore } from "partial-content/azure";
|
|
10
|
+
*
|
|
11
|
+
* const client = BlobServiceClient.fromConnectionString(connectionString);
|
|
12
|
+
* const store = azureStore({
|
|
13
|
+
* containerClient: client.getContainerClient("documents"),
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
import { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError, type ObjectStore } from "./index.js";
|
|
20
|
+
export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
|
|
21
|
+
/**
|
|
22
|
+
* Minimal Azure ContainerClient interface.
|
|
23
|
+
*
|
|
24
|
+
* Declared locally to avoid a hard dependency on `@azure/storage-blob`.
|
|
25
|
+
* Users who import `partial-content/azure` will have it installed as an
|
|
26
|
+
* optional peer dependency.
|
|
27
|
+
*/
|
|
28
|
+
interface AzureContainerClient {
|
|
29
|
+
getBlobClient(blobName: string): AzureBlobClient;
|
|
30
|
+
}
|
|
31
|
+
interface AzureBlobClient {
|
|
32
|
+
getProperties(options?: {
|
|
33
|
+
abortSignal?: AbortSignal;
|
|
34
|
+
}): Promise<AzureBlobProperties>;
|
|
35
|
+
download(offset?: number, count?: number, options?: {
|
|
36
|
+
conditions?: {
|
|
37
|
+
ifMatch?: string;
|
|
38
|
+
};
|
|
39
|
+
abortSignal?: AbortSignal;
|
|
40
|
+
}): Promise<AzureBlobDownloadResponse>;
|
|
41
|
+
}
|
|
42
|
+
interface AzureBlobProperties {
|
|
43
|
+
contentLength?: number;
|
|
44
|
+
etag?: string;
|
|
45
|
+
lastModified?: Date;
|
|
46
|
+
}
|
|
47
|
+
interface AzureBlobDownloadResponse {
|
|
48
|
+
readableStreamBody?: NodeJS.ReadableStream & AsyncIterable<Buffer> & {
|
|
49
|
+
destroy?: () => void;
|
|
50
|
+
};
|
|
51
|
+
blobBody?: Promise<Blob>;
|
|
52
|
+
contentLength?: number;
|
|
53
|
+
/** Present on ranged downloads: "bytes 5-9/20". Carries the total size. */
|
|
54
|
+
contentRange?: string;
|
|
55
|
+
etag?: string;
|
|
56
|
+
lastModified?: Date;
|
|
57
|
+
}
|
|
58
|
+
export interface AzureStoreOptions {
|
|
59
|
+
/** Pre-configured Azure ContainerClient. */
|
|
60
|
+
containerClient: AzureContainerClient;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Create an {@link ObjectStore} backed by an Azure Blob Storage container.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* import { BlobServiceClient } from "@azure/storage-blob";
|
|
68
|
+
* import { azureStore } from "partial-content/azure";
|
|
69
|
+
*
|
|
70
|
+
* const blobService = BlobServiceClient.fromConnectionString(conn);
|
|
71
|
+
* const store = azureStore({
|
|
72
|
+
* containerClient: blobService.getContainerClient("documents"),
|
|
73
|
+
* });
|
|
74
|
+
*
|
|
75
|
+
* const meta = await store.headObject("reports/q4.pdf");
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export declare function azureStore(opts: AzureStoreOptions): ObjectStore;
|
|
79
|
+
//# sourceMappingURL=azure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"azure.d.ts","sourceRoot":"","sources":["../src/azure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,qBAAqB,EAOrB,KAAK,WAAW,EAKjB,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,CAAC;AAI1E;;;;;;GAMG;AACH,UAAU,oBAAoB;IAC5B,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,CAAC;CAClD;AAED,UAAU,eAAe;IACvB,aAAa,CAAC,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IACrF,QAAQ,CACN,MAAM,CAAC,EAAE,MAAM,EACf,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE;YAAE,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,WAAW,CAAC,EAAE,WAAW,CAAA;KAAE,GACzE,OAAO,CAAC,yBAAyB,CAAC,CAAC;CACvC;AAED,UAAU,mBAAmB;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,IAAI,CAAC;CACrB;AAED,UAAU,yBAAyB;IACjC,kBAAkB,CAAC,EAAE,MAAM,CAAC,cAAc,GAAG,aAAa,CAAC,MAAM,CAAC,GAAG;QAAE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;IAC9F,QAAQ,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,IAAI,CAAC;CACrB;AAID,MAAM,WAAW,iBAAiB;IAChC,4CAA4C;IAC5C,eAAe,EAAE,oBAAoB,CAAC;CACvC;AAID;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,WAAW,CA2G/D"}
|
package/dist/azure.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Blob Storage ObjectStore adapter for partial-content.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@azure/storage-blob` to implement the {@link ObjectStore} interface.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { BlobServiceClient } from "@azure/storage-blob";
|
|
9
|
+
* import { azureStore } from "partial-content/azure";
|
|
10
|
+
*
|
|
11
|
+
* const client = BlobServiceClient.fromConnectionString(connectionString);
|
|
12
|
+
* const store = azureStore({
|
|
13
|
+
* containerClient: client.getContainerClient("documents"),
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
import { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError, classifyStoreRead, nodeStreamToWeb, guardStreamLength, resolveServedRange, parseRetryAfterSeconds, isOpenEndedRange, } from "./index.js";
|
|
20
|
+
// Re-export for convenience
|
|
21
|
+
export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
|
|
22
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* Create an {@link ObjectStore} backed by an Azure Blob Storage container.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { BlobServiceClient } from "@azure/storage-blob";
|
|
29
|
+
* import { azureStore } from "partial-content/azure";
|
|
30
|
+
*
|
|
31
|
+
* const blobService = BlobServiceClient.fromConnectionString(conn);
|
|
32
|
+
* const store = azureStore({
|
|
33
|
+
* containerClient: blobService.getContainerClient("documents"),
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* const meta = await store.headObject("reports/q4.pdf");
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function azureStore(opts) {
|
|
40
|
+
const { containerClient } = opts;
|
|
41
|
+
return {
|
|
42
|
+
supportsRange: true,
|
|
43
|
+
// 206 bounds/total come from Azure's actual contentRange: the
|
|
44
|
+
// orchestrator may skip the validating HEAD for plain range requests.
|
|
45
|
+
authoritativeRange: true,
|
|
46
|
+
async headObject(key, opts) {
|
|
47
|
+
opts?.signal?.throwIfAborted();
|
|
48
|
+
// Forward the signal so a client disconnect during the request/header
|
|
49
|
+
// phase cancels the Azure call (the pre-check only covers already-aborted;
|
|
50
|
+
// the body phase is covered by nodeStreamToWeb's signal wiring).
|
|
51
|
+
const props = await classifyStoreRead(key, () => containerClient.getBlobClient(key).getProperties({ abortSignal: opts?.signal }), azureClassifiers);
|
|
52
|
+
if (props.contentLength == null) {
|
|
53
|
+
throw new Error(`Azure getProperties returned no contentLength for ${key}`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
contentLength: props.contentLength,
|
|
57
|
+
etag: props.etag,
|
|
58
|
+
lastModified: props.lastModified?.toUTCString(),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
async getObject(key, opts) {
|
|
62
|
+
const { range, signal, ifMatch } = opts ?? {};
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
// Single round-trip: download() carries everything needed. For ranged
|
|
65
|
+
// reads the response's Content-Range ("bytes 5-9/20") supplies the
|
|
66
|
+
// total size; for full reads contentLength IS the total. One response
|
|
67
|
+
// also means one representation -- no metadata/body race to guard.
|
|
68
|
+
const offset = range ? range.start : 0;
|
|
69
|
+
// An open-ended fast-path range (OPEN_ENDED sentinel end) reads to the
|
|
70
|
+
// end of the blob: leave count undefined so download() streams the
|
|
71
|
+
// tail rather than requesting ~9e15 bytes.
|
|
72
|
+
const count = range && !isOpenEndedRange(range)
|
|
73
|
+
? (range.end - range.start + 1)
|
|
74
|
+
: undefined;
|
|
75
|
+
const response = await classifyStoreRead(key, () => containerClient.getBlobClient(key).download(offset, count, {
|
|
76
|
+
// Pin the read to the validated representation: Azure rejects
|
|
77
|
+
// with 412 ConditionNotMet if the blob changed since HEAD.
|
|
78
|
+
...(ifMatch ? { conditions: { ifMatch } } : {}),
|
|
79
|
+
// Forward the signal so the request/header phase is cancellable too.
|
|
80
|
+
abortSignal: signal,
|
|
81
|
+
}), azureClassifiers);
|
|
82
|
+
if (response.contentLength == null) {
|
|
83
|
+
destroyAzureDownload(response);
|
|
84
|
+
throw new Error(`Azure download returned no contentLength for ${key}`);
|
|
85
|
+
}
|
|
86
|
+
const contentLength = response.contentLength;
|
|
87
|
+
const resolved = response.contentRange
|
|
88
|
+
? resolveServedRange(response.contentRange)
|
|
89
|
+
: null;
|
|
90
|
+
// A Content-Range Azure emits but the resolver cannot parse means the
|
|
91
|
+
// byte accounting is untrustworthy: release the live socket and fail
|
|
92
|
+
// loudly rather than guess.
|
|
93
|
+
if (response.contentRange && !resolved) {
|
|
94
|
+
destroyAzureDownload(response);
|
|
95
|
+
throw new Error(`Azure returned unparseable Content-Range for ${key}: ${response.contentRange}`);
|
|
96
|
+
}
|
|
97
|
+
// For a full read (no Content-Range) contentLength IS the total; a ranged
|
|
98
|
+
// read carries the resolver's honest total (undefined for `bytes a-b/*`).
|
|
99
|
+
const totalSize = resolved ? resolved.totalSize : contentLength;
|
|
100
|
+
// Azure SDK returns different body types depending on the environment
|
|
101
|
+
let webStream;
|
|
102
|
+
if (response.readableStreamBody) {
|
|
103
|
+
// Node.js environment: use shared stream conversion
|
|
104
|
+
const nodeStream = response.readableStreamBody;
|
|
105
|
+
// nodeStreamToWeb auto-detects the stream's destroy() capability.
|
|
106
|
+
// expectedBytes guards a graceful short-read: the Azure SDK can end
|
|
107
|
+
// readableStreamBody cleanly when its in-stream retries are exhausted
|
|
108
|
+
// mid-download, which would otherwise under-run the committed
|
|
109
|
+
// Content-Length undetected.
|
|
110
|
+
webStream = nodeStreamToWeb(nodeStream, { signal, expectedBytes: contentLength });
|
|
111
|
+
}
|
|
112
|
+
else if (response.blobBody) {
|
|
113
|
+
// Browser environment: guard the buffered blob's byte count against the
|
|
114
|
+
// committed length for parity with the Node path's expectedBytes check.
|
|
115
|
+
const blob = await response.blobBody;
|
|
116
|
+
webStream = guardStreamLength(blob.stream(), contentLength);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
throw new Error(`Azure download returned no body for ${key}`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
body: webStream,
|
|
123
|
+
contentLength,
|
|
124
|
+
totalSize,
|
|
125
|
+
// The bounds Azure ACTUALLY served, from its response Content-Range.
|
|
126
|
+
range: resolved ? resolved.served : undefined,
|
|
127
|
+
etag: response.etag,
|
|
128
|
+
lastModified: response.lastModified?.toUTCString(),
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Azure `RestError`'s numeric HTTP status, when present. This is the
|
|
136
|
+
* AUTHORITATIVE classification signal: when a numeric `statusCode` exists, the
|
|
137
|
+
* classifiers key off it exactly and never fall through to message heuristics,
|
|
138
|
+
* so a `503 ServerBusy` whose message text happens to contain "412" (a request
|
|
139
|
+
* id or timestamp digit run) can never be misread as a 412. A present-but-
|
|
140
|
+
* non-numeric status returns `undefined` so the code/message fallback still runs.
|
|
141
|
+
*/
|
|
142
|
+
function azureStatusCode(err) {
|
|
143
|
+
if (typeof err === "object" && err !== null && "statusCode" in err) {
|
|
144
|
+
const s = err.statusCode;
|
|
145
|
+
if (typeof s === "number")
|
|
146
|
+
return s;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Azure `RestError`'s stable error code (`BlobNotFound`, `ConditionNotMet`,
|
|
152
|
+
* `ServerBusy`, ...). Matched exactly, so unlike a message substring it cannot
|
|
153
|
+
* collide with arbitrary text in the error message.
|
|
154
|
+
*/
|
|
155
|
+
function azureErrorCode(err) {
|
|
156
|
+
if (typeof err === "object" && err !== null && "code" in err) {
|
|
157
|
+
const c = err.code;
|
|
158
|
+
if (typeof c === "string")
|
|
159
|
+
return c;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function isAzureNotFound(err) {
|
|
164
|
+
const status = azureStatusCode(err);
|
|
165
|
+
if (status !== undefined)
|
|
166
|
+
return status === 404;
|
|
167
|
+
if (azureErrorCode(err) === "BlobNotFound")
|
|
168
|
+
return true;
|
|
169
|
+
return err instanceof Error && err.name === "RestError" && err.message.includes("BlobNotFound");
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Release Azure's live download socket before bailing out on a malformed
|
|
173
|
+
* response. `download()` resolves once headers arrive with an OPEN
|
|
174
|
+
* `readableStreamBody`; throwing before that stream is handed to
|
|
175
|
+
* `nodeStreamToWeb` would leak the socket (and its connection-pool slot)
|
|
176
|
+
* until GC. The browser `blobBody` path buffers in memory and needs no
|
|
177
|
+
* teardown.
|
|
178
|
+
*/
|
|
179
|
+
function destroyAzureDownload(response) {
|
|
180
|
+
response.readableStreamBody?.destroy?.();
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Check if an Azure error is a failed ifMatch condition (blob changed).
|
|
184
|
+
* The SDK surfaces this as a RestError with statusCode 412 (ConditionNotMet).
|
|
185
|
+
* A numeric status is authoritative; the `code`/message fallback only runs for
|
|
186
|
+
* status-less shapes, so a throttle carrying "412" in its message text is never
|
|
187
|
+
* misreported as a precondition failure.
|
|
188
|
+
*/
|
|
189
|
+
function isAzurePreconditionFailed(err) {
|
|
190
|
+
const status = azureStatusCode(err);
|
|
191
|
+
if (status !== undefined)
|
|
192
|
+
return status === 412;
|
|
193
|
+
if (azureErrorCode(err) === "ConditionNotMet")
|
|
194
|
+
return true;
|
|
195
|
+
return err instanceof Error && err.name === "RestError" && err.message.includes("ConditionNotMet");
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The back-off Azure advises on a throttle, parsed from the `Retry-After`
|
|
199
|
+
* response header (whole seconds; the HTTP-date form is ignored). Surfaced so
|
|
200
|
+
* the 503 echoes it and shared caches wait the advised interval.
|
|
201
|
+
*/
|
|
202
|
+
function azureRetryAfterSeconds(err) {
|
|
203
|
+
const headers = err
|
|
204
|
+
.response?.headers;
|
|
205
|
+
// The classifier runs on arbitrary (S3-compatible/proxy) error shapes, so
|
|
206
|
+
// harden the structural access: optional-call guards only null/undefined, but
|
|
207
|
+
// a truthy non-function `.get` would throw, and a throwing `.get` propagates
|
|
208
|
+
// -- either way the thrown error would replace the throttle and turn a 429/503
|
|
209
|
+
// into a 502. Gate on the type and swallow a throwing getter.
|
|
210
|
+
const get = headers?.get;
|
|
211
|
+
if (typeof get !== "function")
|
|
212
|
+
return undefined;
|
|
213
|
+
let raw;
|
|
214
|
+
try {
|
|
215
|
+
raw = get.call(headers, "retry-after");
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
return parseRetryAfterSeconds(raw);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Whether an Azure error is a transient throttle/overload the client should
|
|
224
|
+
* retry (mapped to a 503, not a 502). Azure's retryable transients are
|
|
225
|
+
* `ServerBusy` (HTTP 503) and `OperationTimedOut` (HTTP **500**), so they are
|
|
226
|
+
* matched by ERROR CODE first: keying only off the numeric status would demote
|
|
227
|
+
* `OperationTimedOut` to a non-retryable 502 (500 is neither 429 nor 503). A
|
|
228
|
+
* numeric 429/503 still catches throttles that arrive without a code, and the
|
|
229
|
+
* message fallback (gated on a status-less shape) preserves the classifier's
|
|
230
|
+
* exclusivity -- a 503 whose message merely contains "412" is classified by
|
|
231
|
+
* code/status, never by substring. Returns the advised `Retry-After` when set.
|
|
232
|
+
*/
|
|
233
|
+
function isAzureThrottled(err) {
|
|
234
|
+
const code = azureErrorCode(err);
|
|
235
|
+
const status = azureStatusCode(err);
|
|
236
|
+
const throttled = code === "ServerBusy" || code === "OperationTimedOut"
|
|
237
|
+
|| status === 429 || status === 503
|
|
238
|
+
|| (status === undefined && err instanceof Error && err.name === "RestError"
|
|
239
|
+
&& (err.message.includes("ServerBusy") || err.message.includes("OperationTimedOut")));
|
|
240
|
+
if (!throttled)
|
|
241
|
+
return false;
|
|
242
|
+
const retryAfterSeconds = azureRetryAfterSeconds(err);
|
|
243
|
+
return retryAfterSeconds !== undefined ? { retryAfterSeconds } : true;
|
|
244
|
+
}
|
|
245
|
+
/** The ordered error-classification set shared by getProperties and download. */
|
|
246
|
+
const azureClassifiers = {
|
|
247
|
+
notFound: isAzureNotFound,
|
|
248
|
+
changed: isAzurePreconditionFailed,
|
|
249
|
+
throttled: isAzureThrottled,
|
|
250
|
+
};
|
|
251
|
+
//# sourceMappingURL=azure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"azure.js","sourceRoot":"","sources":["../src/azure.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Disposition header builder (RFC 6266 + RFC 8187).
|
|
3
|
+
*
|
|
4
|
+
* Builds Content-Disposition headers for HTTP responses that serve files.
|
|
5
|
+
* Handles both inline (in-app preview) and attachment (download) dispositions
|
|
6
|
+
* with proper RFC compliance for non-ASCII filenames.
|
|
7
|
+
*
|
|
8
|
+
* Security: Prevents CRLF header injection, path traversal, bidi override
|
|
9
|
+
* spoofing, and control character injection in filenames from untrusted
|
|
10
|
+
* sources (integration APIs, user uploads).
|
|
11
|
+
*
|
|
12
|
+
* Emits dual filename parameters for cross-browser compatibility:
|
|
13
|
+
* - `filename="ascii-safe.pdf"` for legacy browsers
|
|
14
|
+
* - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
|
|
15
|
+
*/
|
|
16
|
+
export interface ContentDispositionOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Disposition type.
|
|
19
|
+
* - `"attachment"`: Force download (safe default for untrusted content)
|
|
20
|
+
* - `"inline"`: Render in browser (only for previewable types: PDF, image, video, audio)
|
|
21
|
+
* @default "attachment"
|
|
22
|
+
*/
|
|
23
|
+
type?: "attachment" | "inline";
|
|
24
|
+
/**
|
|
25
|
+
* Fallback filename when input is null, undefined, or empty after sanitization.
|
|
26
|
+
* @default "document"
|
|
27
|
+
*/
|
|
28
|
+
fallback?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build a Content-Disposition header value with RFC compliance and
|
|
32
|
+
* security sanitization.
|
|
33
|
+
*
|
|
34
|
+
* Accepts untrusted filenames from integration APIs, user uploads, and
|
|
35
|
+
* database records. Sanitizes for:
|
|
36
|
+
* - CRLF injection (strips \r, \n, and all control characters)
|
|
37
|
+
* - Path traversal (strips ../ and ..\\ sequences)
|
|
38
|
+
* - Basename extraction (strips directory paths)
|
|
39
|
+
* - Bidi override stripping (prevents RLO filename spoofing)
|
|
40
|
+
* - Double-quote and backslash escaping (RFC 2616 quoted-pair)
|
|
41
|
+
*
|
|
42
|
+
* Emits dual filename parameters for cross-browser compatibility:
|
|
43
|
+
* - `filename="ascii-safe.pdf"` for legacy browsers (IE, old Safari)
|
|
44
|
+
* - `filename*=UTF-8''%C3%85rlig.pdf` for modern browsers (RFC 8187)
|
|
45
|
+
*
|
|
46
|
+
* Token optimization: Simple ASCII filenames like `plans.pdf` are emitted
|
|
47
|
+
* unquoted (`filename=plans.pdf`).
|
|
48
|
+
*
|
|
49
|
+
* @param filename - Raw filename from untrusted source (upload, API, DB)
|
|
50
|
+
* @param options - Disposition type and fallback configuration
|
|
51
|
+
* @returns Complete Content-Disposition header value string
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // Simple ASCII attachment
|
|
55
|
+
* buildContentDisposition("report.pdf")
|
|
56
|
+
* // => 'attachment; filename=report.pdf'
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* // Non-ASCII filename (Danish)
|
|
60
|
+
* buildContentDisposition("Årlig_Rapport.pdf")
|
|
61
|
+
* // => 'attachment; filename="?rlig_Rapport.pdf"; filename*=UTF-8\'\'%C3%85rlig_Rapport.pdf'
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* // Inline disposition for preview
|
|
65
|
+
* buildContentDisposition("slides.pdf", { type: "inline" })
|
|
66
|
+
* // => 'inline; filename=slides.pdf'
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* // Null input with custom fallback
|
|
70
|
+
* buildContentDisposition(null, { fallback: "export.csv" })
|
|
71
|
+
* // => 'attachment; filename=export.csv'
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildContentDisposition(filename: string | null | undefined, options?: ContentDispositionOptions): string;
|
|
74
|
+
//# sourceMappingURL=content-disposition.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-disposition.d.ts","sourceRoot":"","sources":["../src/content-disposition.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,MAAM,WAAW,yBAAyB;IACtC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,YAAY,GAAG,QAAQ,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AA2DD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,uBAAuB,CACnC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,CAAC,EAAE,yBAAyB,GACpC,MAAM,CAkBR"}
|