Audit of 0.8.4 found the repo's own gates green, so these came from manual
review; each is covered by a new regression test.
security/fetch.ts
- safeFetch re-attached Authorization/Cookie on a same-origin redirect that
followed a cross-origin hop (a -> b -> b), handing credentials to the second
host. Compare against the origin the caller trusted, not the previous hop.
- The private-network guard resolved the host, approved it, then let fetch
resolve again, so a low-TTL record could answer public for the check and
private for the connection. Pin the connection to the validated address,
preserving Host and TLS serverName. Opt out with pinDns: false.
- 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as
private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15
and 192.0.0/24.
security/url.ts
- sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path,
bypassing the host checks it had just run; in an href that navigates
cross-origin. Resolve protocol-relative input instead.
dev-server/gateway.ts
- Malformed base64 in an Authorization header threw out of checkAuth on an
unauthenticated path. Fail closed.
- split(":", 2) truncated passwords at the first colon, so a password
containing ":" could never authenticate.
- The credential compare short-circuited on length mismatch, leaking length
by timing. Extracted as verifyBasicAuth so it is testable.
authz/index.ts
- Namespace wildcards only matched the first segment, so "post:comment:*"
did not grant "post:comment:delete". Match at every depth.
uploader/operations.ts
- Validate transcoder dimensions and bitrate rather than trusting the declared
type, and reject ".." path segments.
package.json
- The brace-expansion override pinned 5.0.8, which is inside the advisory
range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean.
Verified: check:production passes (typecheck, lint, 1033 tests, format,
ASVS, public-API baseline, editor checks).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wrnexus/uploader
Config-driven file uploads + serving for WrNexus. Declare
named storage stores (local disk or any S3-compatible backend) in wrnexus.config.ts, upload
with one function call, drop a drag-and-drop widget on a page, and serve files back — public or
private. Zero external dependencies (S3 is signed with a built-in AWS SigV4 implementation, like the
rest of the framework).
Usage
Configure local and S3 stores
// wrnexus.config.ts
import type { AppConfig } from "@wrnexus/styles";
const config: AppConfig = {
storage: {
default: "public",
stores: {
// Local disk, world-readable — served by the framework with a 1-year cache.
public: {
driver: "local",
dir: "uploads/public", // relative to the app root (dev) / cwd (prod)
access: "public",
maxBytes: 10_000_000,
accept: ["image/*", ".pdf"], // MIME, "type/*" wildcards, or ".ext"
},
// Private S3 (works with AWS, Cloudflare R2, Backblaze B2, MinIO, DO Spaces).
docs: {
driver: "s3",
access: "private",
bucket: "my-bucket",
region: "auto",
endpoint: "https://<acct>.r2.cloudflarestorage.com",
accessKeyId: process.env.S3_KEY!,
secretAccessKey: process.env.S3_SECRET!,
},
},
},
};
export default config;
Upload from an API route or server function
// app/api/upload.ts — one-liner
import { handleUpload } from "@wrnexus/uploader";
export const POST = handleUpload({ store: "public" });
// → { ok: true, files: [{ key, url, name, type, size }] }
// or drive it yourself, anywhere you have the request
import { upload, getStore } from "@wrnexus/uploader";
const { files } = await upload("docs", ctx.req, { prefix: "invoices" });
await getStore("docs").driver.delete(files[0].key);
Uploads are validated (size + type), stored under a random, collision-proof, path-safe key
(the client filename is never used as a path), and — for public stores — returned with a servable
url.
Add a client upload widget
Drop the element anywhere; the runtime (drag-and-drop, per-file progress, success/failed states) is
auto-injected on pages that contain data-uploader:
<div
data-uploader="public"
data-endpoint="/api/upload"
data-accept="image/*"
data-max="10000000"
data-multiple
></div>
Or via the first-party UI component:
<div
data-component="file-upload"
store="public"
endpoint="/api/upload"
accept="image/*"
multiple="true"
></div>
It dispatches bubbling events you can listen for:
wrnexus:upload—detail: { file, result: { key, url, name, size, type } }wrnexus:upload-error—detail: { file, error }
Serve private files behind application authentication
- Public + local → served automatically at
/__wrnexus/uploads/<store>/<key>(immutable cache). - Public + S3 →
urlis the bucket/CDN URL directly. - Private (any driver) → mount a route and gate it with your auth middleware:
// app/api/files/[key].ts
import { serveFromStore } from "@wrnexus/uploader";
export const GET = serveFromStore("docs"); // your middleware decides who gets in
API
Uploads can participate in security and media pipelines without changing storage drivers. Pass a
scan hook to reject malware/DLP findings before storage, and afterStore to enqueue image/video
processing or indexing. If post-processing throws, WRNexus deletes the newly written object so a
partially accepted upload is never left behind.
| Export | What |
|---|---|
handleUpload(opts) |
POST route handler → JSON { ok, files } |
upload(store, req, opts) |
Parse + validate + store; returns { files } |
serveFromStore(store) |
Route handler that streams an object back (gate it for private) |
getStore(name?) / hasStorage(name?) |
Reach a store's driver (put/get/delete/publicUrl) |
configureStorage(config, root) |
Build the registry (the framework calls this at startup) |
s3Driver / localDriver / signS3 |
Lower-level building blocks |
Notes
- Uploads count against the server's
maxBodyBytes; per-file limits use each store'smaxBytes. - SigV4 signing is implemented from scratch (no
@aws-sdk); tested against local S3 semantics. Live AWS/R2 connectivity depends on your credentials + bucket policy. - v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB).
Helper and component kit
Use formatFileSize, uploadAccept, uploadedFileMap, uploaderAttributes, and assertUploadedFiles to keep upload forms and server validation consistent.
Enable uploaderPlugin() for:
<UploadDropzone /><UploadStatus />
The complete blocks compose Card, Alert, and Badge from @wrnexus/ui; the specialized upload runtime remains responsible for the native file input and secure transport behavior.
Large files can use createResumableUploadManager. Sessions are bounded and
expiring; chunks may arrive out of order, carry SHA-256 checksums, and are
idempotent when retried. Conflicting retries reject, and the object is assembled
only after every exact-sized chunk is present.
const uploads = createResumableUploadManager({
driver: getStore("documents").driver,
sessions: redisUploadSessionStore,
chunkSize: 5 * 1024 * 1024,
maxBytes: 500 * 1024 * 1024,
accept: ["application/pdf"],
});
const session = await uploads.create({ name: "report.pdf", size, type });
await uploads.uploadChunk(session.id, index, bytes, sha256);
The included memory session store is intended for one-process apps and tests.
Multi-instance production deployments should implement ResumableSessionStore
with shared durable storage and atomic session updates, and periodically call
prune() for abandoned uploads.