Перейти к содержанию

API Reference

All responses include standard headers: Server, Date, Connection, and X-Content-Type-Options: nosniff (close by default, keep-alive when the server keeps the socket open). CORS headers are only emitted when CORS is explicitly enabled. Error response bodies are endpoint-specific. Most shared handler and pipeline errors use JSON format {"error": "message", "status": NNN}, but several legacy paths return text, an empty body, or endpoint-specific JSON as documented below.

Contract Stability

The surface described in this document is the legacy v0 API contract. The server does not currently implement /api/v1 endpoints or a versioned API prefix; clients should discover the active v0 method surface with PING.

The v0 compatibility promise is intentionally narrow:

  • Documented method names, route shapes, filesystem scopes, and stable PING discovery fields should not be removed or renamed without a release note and migration path.
  • PING fields supported_methods, plugin_methods, access_scope, and metrics are the stable discovery mechanism for clients. Treat supported_methods and plugin_methods as sets. The additive method_groups object is presentation metadata and must not be treated as independent capability negotiation.
  • Response examples may gain additive JSON fields. Clients should ignore fields they do not understand unless this document marks the field as required.
  • Legacy error bodies are not normalized across all endpoints. Status codes and documented stable fields are the contract; exact human-readable error text, JSON formatting, object key order, and mixed legacy text/empty bodies are not a v1-style error schema.
  • Generated temporary names, generated request IDs, precise timing values, and low-level counter names are operational diagnostics, not a stable client API. They may change as long as PING discovery remains available.
  • Advanced upload fallback, SMUGGLE, NOTE, and WebSocket notes are legacy v0 behavior in the single full mode; they are not a promise that the same shape will become the future v1 API.

The built-in browser UI, bundled examples, and operator-owned scripts are reference consumers of this legacy surface, not an official SDK or a public client support program with broader compatibility guarantees.

There is no global idempotency key in v0. Read-only methods (GET, HEAD, FETCH, INFO, PING, and OPTIONS) are the safest retry targets. Mutating methods can create, replace, delete, or clear state before a client observes a timeout. Retrying a mutating request should use a stable filename or note ID when the endpoint supports one, then confirm final state with INFO, FETCH, NOTE /notes, or NOTE /notes/{id}. X-Request-Id is generated by the server for log correlation after dispatch, not client deduplication.

NOTE IDs are legacy v0 identifiers. Server-generated note IDs are 32 lowercase hex characters. Current v0 validation accepts client-supplied lowercase hex IDs from 1 to 32 characters for existing-note paths and idempotent saves, but clients should send 32-character IDs for forward compatibility. For idempotent NOTE creates, send a stable id (HTTP) or noteId (WebSocket) with createIfMissing: true. WebSocket opId is only echoed for acknowledgement correlation; it is not stored, replayed, or used to deduplicate saves.

Transport-layer failures are distinct from handler responses. Some receive-layer framing failures close the TCP connection before an HTTP response exists, and WebSocket protocol failures after a successful upgrade use WebSocket close frames instead of HTTP JSON bodies. The response-body tables below apply only when the server emits an HTTP response or an application-level WebSocket JSON frame.

ADR-010 defers /api/v1 and official SDK/public-client work until explicit versioning, error/idempotency, feature-selection, security-boundary, and ownership decisions are approved. If v1 work ever starts, it should be explicit and opt-in, such as an /api/v1/... route family or another versioned negotiation mechanism. A small v1 surface would likely define normalized JSON errors, explicit idempotency, versioned discovery, ordinary upload/list/delete operations, NOTE CRUD, and a versioned notes WebSocket contract. None of that v1 surface exists today.

Error Response Bodies

Surface Error body contract
GET 404 uses JSON {"error": "File not found: <path>", "status": 404}.
HEAD Same status and headers as GET, but with an empty body.
POST / PUT / PATCH / NONE Empty uploads return JSON {"success": false, "error": "...", "hint": "..."}. Write failures return JSON {"success": false, "error": "..."}. Upload-size failures that reach the handler/pipeline use JSON {"error": "...", "status": 413}; receive-layer oversized request guards can close before dispatch.
DELETE File/path validation errors generally use JSON {"error": "...", "status": NNN}. Clear-upload failures use endpoint JSON with success, error, deletion counters, preserved, and errors.
FETCH Missing files return legacy text/plain body Cannot fetch: <path> with X-Fetch-Status: file-not-found.
INFO Invalid paths return legacy text/plain body Invalid path. Missing paths return JSON {"exists": false, "path": "<path>"}. Hidden paths use the shared JSON error body.
SMUGGLE Missing files, builder validation, source-size, and temp-retention failures return JSON with human error, numeric status, and a stable machine-readable code; field-specific validation may also include field. Existing legacy keys such as path, size fields, and error are preserved for backward compatibility.
NOTE HTTP Validation, missing-note, and crypto-unavailable errors use JSON {"error": "...", "status": NNN}. NOTE /notes/key reports crypto availability in its normal 200 response.
WebSocket upgrade and messages Auth failures can return 401/429 JSON before WebSocket validation. HTTP upgrade rejections (400, 403, 501, 503) use JSON {"error": "...", "status": NNN} before the WebSocket handshake. After upgrade, application-message errors are WebSocket JSON text frames such as {"type": "error", "error": "..."} or operation frames that may include error and status; protocol/frame failures close with WebSocket close frames instead.
Advanced upload Unknown methods carrying an advanced payload in a supported body format, headers, query string, cookies, or an explicitly marked path segment are routed to advanced upload. Unknown methods without an advanced payload return shared JSON 405. Some validation errors use JSON {"error": "...", "status": 400}. Missing advanced payloads after dispatch return 400 application/json containing status: 400 and the standard always-on upload diagnostic fields; the body is not empty text. HMAC failures return JSON {"ok": false, "err": "hmac"}. Write failures return JSON {"ok": false}.
Advanced routing control Malformed or incomplete PUT JSON, malformed diagnostics-only PATCH JSON, and invalid prefix/decoder values return shared JSON 400. A plugin conflict returns JSON 409 only for PUT. Unauthorized control access returns JSON 403. Other methods return JSON 405 with Allow: GET, PUT, PATCH, DELETE.
Auth and request guards Basic-auth failures, auth rate limits, internal pipeline errors, and aggregate body-memory budget exhaustion use JSON {"error": "...", "status": NNN}. Other receive-layer framing failures such as unsupported Transfer-Encoding, conflicting or invalid Content-Length, declared Content-Length over the configured upload cap, receive timeouts, or requests that exceed the receive hard cap may close the connection without an HTTP error body.

Full Method Surface

XFerry has one always-on core method surface. The handler registry, exact-origin CORS preflight, browser UI affordances, and WebSocket notes all use this full surface by default. Core methods are:

GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, FETCH, INFO, PING, NONE, NOTE, SMUGGLE, plus unknown non-standard methods carrying an advanced-upload payload.

Wildcard CORS remains read-only and lists only read methods; exact CORS origins can receive the full method list and can echo a requested unknown advanced upload method when the method token is valid.


Request Framing and Caps

The receive layer enforces protocol framing before handler dispatch:

  • Request headers are capped by --max-header-size KB (64 KiB by default) before the terminating blank line.
  • Request bodies are capped by --max-size MB (100 MiB by default) using the declared Content-Length and the bytes actually read.
  • Concurrent in-flight request bodies are reserved against --body-memory-budget MB. The default budget is --workers * --max-size. Aggregate budget exhaustion returns 503 before the remaining body bytes are read.
  • Active WebSocket upgrades are capped by --max-websocket-connections N. The default is --workers // 2; 0 rejects all WebSocket admissions with 503.
  • Incomplete WebSocket frames are capped by --websocket-frame-idle-timeout SECONDS (5 seconds by default). Timeout failures close the WebSocket with protocol close code 1002.
  • Aggregate disk usage under uploads/ is controlled separately with optional --upload-storage-limit MB, --upload-file-limit N, and --upload-reserve-free MB limits. A value of 0 disables each aggregate limit.
  • Encrypted Notepad blobs under notes/ are capped by --note-storage-limit MB and --note-count-limit N (256 MiB and 1000 notes by default). A value of 0 disables each aggregate Notepad limit.
  • Generated one-shot SMUGGLE pages are retained under uploads/ only within --smuggle-temp-age SECONDS, --smuggle-temp-file-limit N, and --smuggle-temp-storage-limit MB (3600 seconds, 32 files, and 128 MiB by default). A value of 0 disables the corresponding retention limit.
  • Transfer-Encoding is unsupported and rejected at the receive layer because the server does not decode chunked request bodies.
  • Invalid, negative, or conflicting duplicate Content-Length values are rejected. Duplicate identical Content-Length values are accepted.
  • Receive-layer framing failures may close the connection before an HTTP error response is built, except aggregate body-memory budget exhaustion, which returns a JSON 503. Rejections are counted in metrics.receive_rejections and summarized under metrics.receive.

GET

Serve the bundled web UI, bundled static assets, and user files from uploads/.

Request:

GET /uploads/path/to/file HTTP/1.1

GET / and GET /index.html serve the built-in UI. GET /static/... serves the built-in UI assets. Other file paths are resolved inside uploads/; /file.txt and /uploads/file.txt both target <root>/uploads/file.txt.

Response: File contents with appropriate Content-Type. Bundled HTML files include Content-Security-Policy; uploaded HTML/SVG files are forced to download as attachments.

The bundled UI CSP currently includes default-src 'self', script-src 'self', style-src 'self' 'unsafe-inline', img-src 'self' data:, connect-src 'self' ws: wss:, base-uri 'self', object-src 'none', frame-ancestors 'none', and form-action 'self'. Inline scripts are blocked. The remaining inline style allowance is limited to current UI progress widgets.

Status codes: 200 OK, 304 Not Modified (if ETag matches), 404 Not Found


Returns the same headers as GET but with no response body. Useful for checking file existence and metadata without transferring content.

Request:

HEAD /uploads/path/to/file HTTP/1.1

Response: Same status code and headers as GET (200 or 404), empty body.

Status codes: 200 OK, 304 Not Modified (if ETag matches), 404 Not Found


Basic Upload: POST / PUT / PATCH / NONE

All four methods use the same Basic handler unless active Advanced prefix routing matches the request path. Basic has three exact wire profiles:

Profile Request target Body/headers Filename source
Multipart (default) /uploads The browser UI sends one FormData file part using field file; the server accepts any non-empty file-part field name. The browser owns the multipart boundary and Content-Length. X-File-Name, then part filename, then URL, then generated
Raw URL /uploads/<encoded-name> Original file bytes, no X-File-Name URL
Raw Header /uploads Original bytes, Content-Type: application/octet-stream, URL-encoded X-File-Name header

Example Raw Header request:

POST /uploads HTTP/1.1
Content-Type: application/octet-stream
X-File-Name: myfile.txt
Content-Length: 1234

<file bytes>

Filename precedence is X-File-Name > multipart file-part filename > URL path > generated timestamp name. The /uploads collection special case applies only to multipart: a raw request to /uploads without X-File-Name saves a literal filename uploads. X-File-Name values are URL-decoded and sanitized before publication. A multipart part filename is parsed and sanitized but is not URL-decoded by XFerry. Collisions receive a safe suffix.

For Basic multipart, scalar form fields are ignored. The request must contain exactly one top-level file part with a non-empty payload. Zero or multiple file parts, an empty file payload, nested multipart parts, malformed boundaries or part headers, and duplicate singleton MIME part headers (Content-Disposition, Content-Type, or Content-Transfer-Encoding) are rejected with 400. Content-Transfer-Encoding may be absent or use binary / 8bit case-insensitively. Unsupported encodings are rejected and are never decoded.

Response (201):

{
  "success": true,
  "filename": "myfile.txt",
  "size": 1234,
  "size_human": "1.2 KB",
  "path": "/uploads/myfile.txt",
  "uploaded_at": "2025-01-15T10:30:00",
  "content_type": "text/plain"
}

Every Basic response, including errors, includes additive JSON diagnostics: dispatch, route_source, route_revision, profile, carrier, filename_source, normalized_filename, collision_renamed, request_body_size, payload_size, file_content_type, and sha256. The digest is SHA-256 over the final payload bytes, not the multipart envelope. The browser comparison uses that digest plus strict diagnostics for the three profiles. Optional response mirrors may be disabled; verdicts report observed equivalence/difference and never claim that an SWG caused the result.

Headers: X-Upload-Status, X-File-Name, X-File-Size, X-File-Path. The six diagnostic mirror headers documented below are optional and default off.

Status codes: 201 Created, 400 No data, 413 Payload too large, 500 Server error


DELETE

Delete a file from uploads/. Only files inside uploads/ can be deleted. To clear the upload workspace, use the explicit clear flag; plain DELETE /uploads still rejects directory deletion.

Request:

DELETE /uploads/filename.txt HTTP/1.1

Response (200):

{
  "success": true,
  "deleted": "filename.txt",
  "path": "/uploads/filename.txt"
}

Clear uploads request:

DELETE /uploads?clear=1 HTTP/1.1

Clear uploads response (200):

{
  "success": true,
  "cleared": true,
  "path": "/uploads",
  "deleted_files": 3,
  "deleted_dirs": 1,
  "preserved": [".gitkeep"]
}

Hidden service files such as .gitkeep are preserved. Current notepad storage lives in the separate top-level notes/ directory; uploads/notes/ is treated as ordinary upload content.

Status codes: 200 OK, 403 Outside uploads/, 404 Not Found, 400 Cannot delete directory


FETCH

Download a file with Content-Disposition: attachment.

Request:

FETCH /uploads/file.txt HTTP/1.1

Response: File contents with download headers.

Headers: Content-Disposition, X-Fetch-Status, X-File-Name, X-File-Size, X-File-Modified

Status codes: 200 OK, 404 Not Found


INFO

Directory listing as JSON. Supports pagination via query parameters. Paths are always resolved inside uploads/; / and /uploads/ both describe the upload workspace.

Request:

INFO /uploads/?offset=0&limit=100 HTTP/1.1

Query parameters: - offset (default: 0) — Skip first N items - limit (default: 100, max: 1000) — Number of items to return - inspect=1 — Opt in to bounded content inspection metadata. Any omitted or other inspect value preserves the legacy response shape and does no content inspection work.

Response (200):

{
  "exists": true,
  "path": "/uploads/",
  "name": "uploads",
  "is_file": false,
  "is_directory": true,
  "size": 4096,
  "size_human": "4.0 KB",
  "content_type": "unknown",
  "created": "2025-01-15T10:30:00",
  "modified": "2025-01-15T10:30:00",
  "extension": "",
  "access_scope": "uploads",
  "total_items": 42,
  "offset": 0,
  "limit": 100,
  "contents": [
    {
      "name": "file.txt",
      "is_dir": false
    }
  ]
}

Directory contents entries include only name and is_dir by default; request INFO for a specific child path to retrieve size, timestamps, content type, and other file metadata for that entry.

Optional content inspection

Request inspection explicitly, for example:

INFO /uploads/report.pdf?inspect=1 HTTP/1.1

The response then gains this additive inspection object for an individual file (or for eligible file entries in a directory listing):

{
  "inspection": {
    "mime_type": "application/pdf",
    "mime_source": "signature",
    "content_state": "recognized",
    "warning": null,
    "reasons": []
  }
}

mime_source is one of signature, text, extension, or unknown. content_state is recognized, opaque, or unknown. warning is null, possible_encrypted_or_packed, or extension_mismatch. reasons may contain encrypted_suffix, extension_mismatch, unrecognized_binary, insufficient_data, or unavailable.

Inspection is heuristic metadata, not a security verdict: no numeric probability or entropy score is reported, and an opaque file never proves XOR encryption. A .enc or .xor suffix is only a reason. Password-protected ZIP-family or other container files are identified only by their outer format, not as proof of their contents or encryption.

The server reads at most 65,536 bytes from the file head. For ZIP-family, PE/SFX, or otherwise opaque candidates it may use a separately bounded, 65,557-byte ZIP-tail sample solely to confirm the outer ZIP format. For a directory request, it sorts and paginates first, then inspects only regular, non-symlink files in the visible page; directories and entries outside that page are not inspected.

Status codes: 200 OK, 400 Invalid path, 404 Not Found


PING

Health check endpoint.

Request:

PING / HTTP/1.1

Response (200):

{
  "status": "pong",
  "server": "XFerry/2.1.0",
  "timestamp": "2025-01-15T10:30:00.123456+00:00",
  "supported_methods": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "FETCH", "INFO", "PING", "NONE", "NOTE", "SMUGGLE"],
  "method_groups": {
    "request": ["GET", "HEAD", "OPTIONS", "INFO", "PING"],
    "upload": ["POST", "PUT", "PATCH", "NONE"],
    "files": ["DELETE", "FETCH", "SMUGGLE"],
    "notepad": ["NOTE"]
  },
  "plugin_methods": [],
  "access_scope": "uploads",
  "metrics": {
    "uptime_seconds": 3600.5,
    "total_requests": 150,
    "total_errors": 2,
    "client_errors": 4,
    "server_errors": 2,
    "bytes_sent": 524288,
    "bytes_received": 1048576,
    "status_counts": {
      "200": 144,
      "404": 4,
      "500": 2
    },
    "receive_rejections": {
      "header_too_large": 1,
      "body_too_large": 1
    },
    "connections": {
      "active": 3,
      "accepted": 150,
      "closed": 147
    },
    "receive": {
      "bytes": 1048576,
      "rejections": 2,
      "rejection_reasons": {
        "header_too_large": 1,
        "body_too_large": 1
      }
    },
    "timeouts": {
      "websocket_incomplete_frame": 1
    },
    "request_admission": {
      "active": 2,
      "accepted": 150,
      "rejected": 1
    },
    "body_memory": {
      "max_bytes": 1048576000,
      "current_bytes": 0,
      "peak_bytes": 52428800,
      "rejected": 0
    },
    "request_latency_ms": {
      "count": 150,
      "total": 2450.75,
      "avg": 16.338,
      "max": 180.25
    },
    "websocket": {
      "active": 0,
      "rejected_admissions": 1,
      "closed": 4,
      "protocol_errors": 0,
      "message_too_big": 0,
      "incomplete_frame_timeouts": 1,
      "idle_pings": 3,
      "errors": 0
    },
    "worker": {
      "exceptions": 0,
      "exception_sources": {},
      "last_exception_type": null
    },
    "storage": {
      "usage": {
        "notes": {"bytes": 4096, "items": 2, "notes": 2},
        "smuggle_temp": {"bytes": 8192, "items": 1, "files": 1},
        "uploads": {"bytes": 532480, "items": 6, "files": 6}
      },
      "quota_denials": {
        "notes": {"bytes": 0, "notes": 0},
        "smuggle_temp": {"bytes": 0, "files": 0},
        "uploads": {
          "bytes": 1,
          "disk_full": 0,
          "files": 0,
          "free_space": 0
        }
      },
      "scans": {
        "info": {
          "count": 3,
          "items": 18,
          "total_ms": 1.2,
          "avg_ms": 0.4,
          "max_ms": 0.6
        },
        "notepad_listing": {
          "count": 1,
          "items": 2,
          "total_ms": 0.3,
          "avg_ms": 0.3,
          "max_ms": 0.3
        },
        "notepad_usage": {
          "count": 2,
          "items": 3,
          "total_ms": 0.4,
          "avg_ms": 0.2,
          "max_ms": 0.25
        },
        "storage_snapshot": {
          "count": 3,
          "items": 9,
          "total_ms": 0.9,
          "avg_ms": 0.3,
          "max_ms": 0.5
        },
        "upload_quota": {
          "count": 4,
          "items": 20,
          "total_ms": 2.4,
          "avg_ms": 0.6,
          "max_ms": 0.8
        }
      }
    },
    "advanced_upload": {
      "decode_rejections": {
        "crypto_unavailable": 0,
        "decoded_too_large": 1,
        "decrypt_failed": 0,
        "encoded_too_large": 0,
        "hmac_mismatch": 0,
        "invalid_encoding": 1,
        "invalid_key": 0
      }
    }
  }
}

supported_methods is the availability source of truth for built-in core methods. method_groups is derived presentation metadata for organizing those methods in clients; it does not enable or disable a feature independently. Clients should treat supported_methods and plugin_methods as sets and ignore unknown additive fields. Plugin methods remain separate and are not inserted into core groups. Legacy discovery fields profile, capabilities, and advanced_upload are no longer emitted.

When the core SMUGGLE implementation is active, PING also includes additive smuggle_capabilities for UI clients. The object is advisory discovery data, not an authorization decision. It includes source_max_bytes, field_limits, defaults, extensions, mime_presets, mime_by_extension, presets, locales, constructor enum lists, trigger_events, trigger_aliases, custom_trigger_methods, temp_policy, and boolean caps. Current defaults, limits, and built-ins are:

  • default simple mode: preset=direct; default constructor mode: locale=ru, encrypt=false, payload_encoding=b64, trigger_method=svg, trigger_event=onload, output_format=html, download_variant=blob-anchor, page_template=default, mime_type=application/octet-stream, null_byte=false, show_notice=true, use_constructor=false
  • field limits: download_name 120 characters, download_ext 32 characters, title 120 characters, message 280 characters, cta_label 80 characters, delay_ms 0..10000, mime_type 120 characters, and trigger_event 64 characters
  • locales: ru, en
  • suggested extracted-file extensions: txt, bin, dat, zip, pdf; extensions is a UI suggestion list, not an allowlist or a content-safety boundary
  • constructor MIME presets cover generic/text (application/octet-stream, text/plain, text/html, text/css, text/csv, text/javascript, application/json, application/xml, application/pdf), archives (application/zip, application/gzip, application/x-tar, application/x-7z-compressed, application/vnd.rar), images/media (image/png, image/jpeg, image/gif, image/webp, image/svg+xml, audio/mpeg, video/mp4), legacy and OOXML Office types (application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation), packages/binaries (application/java-archive, application/vnd.android.package-archive, application/wasm, application/vnd.microsoft.portable-executable, application/x-msi), and scripts (text/x-python, application/x-powershell, application/x-sh)
  • mime_by_extension supplies matching suggestions for bin, dat, txt, log, md, csv, html, htm, css, js, mjs, json, xml, pdf, zip, gz, tgz, compound tar.gz, tar, 7z, rar, png, jpg, jpeg, gif, webp, svg, mp3, mp4, doc, docx, xls, xlsx, ppt, pptx, jar, apk, wasm, exe, dll, scr, msi, py, pyw, ps1, psm1, psd1, sh, bash, and zsh
  • simple presets: direct, card_manual, card_auto
  • payload encodings: b64, base64url, base32, percent, reverse, xor, hex, split, attrs, charcode
  • outer artifact formats: html, htm, shtml, shtm, xhtml, xht, xhtm, xml, svg (this expansion adds no output formats)
  • page templates: default, minimal, corporate, drive, npf-zip-archive-help, npf-rar-archive-help
  • download variants: blob-anchor, data-uri, iframe-blob, filereader, fetch-blob, window-open, loc-assign, form-post, timeout-blob, promise-blob, raf-blob, microtask-blob, observer-blob, response-blob, readable-stream, message-channel-blob, idle-callback-blob
  • trigger map: svg:onload; body:onload,onpageshow; img:onerror,onload; audio:onerror,onloadstart; video:onerror,onloadstart; source:onerror; input:onfocus,oninput,onchange,onkeydown; select:onfocus,onchange; button:onfocus,onclick,onpointerdown,onkeydown; textarea:onfocus,oninput,onchange,onkeydown; details:ontoggle,onclick; iframe:srcdoc,onload; animate:onbegin,onend,onrepeat; animmotion:onbegin,onend,onrepeat; set:onbegin,onend; cssanim:onanimationstart,onanimationend,onanimationiteration; csstransition:ontransitionrun,ontransitionstart,ontransitionend; link:onerror,onload; script:onerror; form:onsubmit; custom:onfocus; focusin:onfocusin; contentvis:oncontentvisibilityautostatechange; pageshow:onpageshow. Clients should still prefer the exact trigger_events map returned by the running server over a hard-coded copy.
  • trigger alias: pageshow resolves to body:onpageshow
  • custom trigger eligibility: custom_trigger_methods lists the canonical, registered element-method tokens that may accept a validated custom event: svg, body, img, audio, video, source, input, select, button, textarea, details, iframe, animate, animmotion, set, cssanim, csstransition, link, script, form, custom, focusin, and contentvis; the pageshow alias is intentionally excluded
  • capability flags: one_shot, constructor, xor_obfuscation, source_cap_enforced, custom_extension, custom_mime_type, custom_trigger_event, and searchable_options are boolean; the current built-in implementation reports all eight as true

The server owns built-in method name, handler binding, mutation, CORS, UI group, and exposure metadata in one typed CoreMethodSpec registry. Handler registration, CORS projections, PING, and the bundled UI are derived from that policy to prevent method drift.

The response also includes the header X-Ping-Response: pong. The same metrics object is available as JSON from GET /metrics.

total_errors is retained for compatibility and matches server_errors. client_errors counts recorded 4xx responses. server_errors counts recorded 5xx responses and exceptional request failures. Handler-returned responses and direct error responses are included in status_counts and the matching error bucket. Receive-layer drops before request dispatch are tracked by receive_rejections reason and summarized under receive.rejections. connections tracks worker-owned accepted sockets. request_admission tracks the bounded worker budget before submission. request_latency_ms contains in-process timing aggregates for processed request pipeline entries. body_memory tracks the aggregate declared request-body budget, current and peak reserved bytes, and aggregate-budget rejections. timeouts uses low-cardinality names for receive and WebSocket timeout signals. Accepted WebSocket upgrades are tracked through the websocket resource counters rather than total_requests, status_counts, or bytes_sent. Worker failures that escape normal request handling are logged and summarized under worker.

storage.usage is refreshed with exact filesystem scans when PING or GET /metrics builds its snapshot. uploads is aggregate regular-file usage under uploads/, including generated SMUGGLE artifacts because they consume the same volume; smuggle_temp is the generated-artifact subset. notes counts encrypted .enc blobs and their bytes, not metadata sidecars.

storage.quota_denials uses only closed labels: upload byte/file/free-space/ disk-full denials, note byte/count denials, and SMUGGLE temporary byte/file denials. advanced_upload.decode_rejections similarly uses the fixed reasons shown above. Paths, filenames, note titles, session IDs, methods, encodings, and exception messages never become metric labels.

storage.scans contains cumulative count, examined items, total_ms, avg_ms, and max_ms for five fixed scopes: info, upload_quota, notepad_usage, notepad_listing, and storage_snapshot. items is cumulative work, not current cardinality. INFO pagination preserves exact totals and therefore sorts/scans the directory in O(n) before slicing the response. Aggregate upload quota checks and exact usage snapshots are also O(n); no cache or storage index is maintained. Consequently PING and GET /metrics are operational snapshots rather than constant-time probes. Increase the probe interval, or use a TCP-only liveness probe when process liveness is sufficient, if storage grows too large for frequent exact scans.


SMUGGLE

Create a temporary same-origin HTML/SVG/XML artifact for a file in uploads/. The SMUGGLE request returns JSON with a temporary URL; clients then open or download that URL to receive the generated artifact. The source file, one-shot artifact, and extracted file are three separate things: changing the download-facing extension or MIME metadata does not convert or validate the embedded bytes.

Request:

SMUGGLE /uploads/file.txt HTTP/1.1

Encode each path segment before adding SMUGGLE query parameters. A raw ? or # in an upload filename changes the request path unless the filename segment is percent-encoded first. For display and follow-up automation, use the server-returned downloadName rather than reimplementing filename normalization in the client.

With encryption:

SMUGGLE /uploads/file.txt?encrypt=1 HTTP/1.1

encrypt=1 stores an XOR-obfuscated payload in the generated HTML page and shows a server-generated password CAPTCHA on that page. This is obfuscation and a manual password gate, not confidentiality or authenticated encryption.

SMUGGLE also accepts a bounded safe-builder layer for neutral internal test artifacts. Legacy requests without these parameters stay backward-compatible. Use locale=ru or locale=en to select localized artifact copy where the renderer provides localized text. Omit locale to use the default ru; unsupported locale values return a 400 SMUGGLE error with code=invalid_smuggle_locale and field=locale.

Safe builder query parameters:

  • download_name: optional download-facing basename.
  • download_ext: optional validated extracted-file suffix, at most 32 characters. txt, bin, dat, zip, and pdf are suggestions rather than an allowlist; a safe custom suffix, including a compound suffix such as tar.gz, is accepted. Each ASCII segment starts with a letter or digit; its remaining characters may also contain _, +, or -. Segments are separated by single dots, and one optional leading dot is normalized away. The suffix changes only the normalized extracted filename, not the embedded bytes, outer artifact format, or MIME metadata.
  • preset: optional fixed shell preset; one of direct, card_manual, or card_auto.
  • title: optional bounded title text rendered inside the generated page.
  • message: optional bounded explanatory copy rendered inside the generated page.
  • cta_label: optional bounded button label for card presets.
  • delay_ms: optional auto-start delay in milliseconds for card_auto (bounded to 0..10000).
  • show_notice: 1 or 0 to keep or hide the visible experimental/test-artifact notice.
  • use_constructor: 1 or 0 to explicitly select constructor mode. An explicit false value combined with any constructor-only parameter is a conflicting configuration and returns 400; the server does not silently discard those parameters. The error uses code=invalid_smuggle_configuration and field=use_constructor.
  • payload_encoding: constructor payload encoding; one of b64, base64url, base32, percent, reverse, xor, hex, split, attrs, or charcode.
  • trigger_method and trigger_event: constructor trigger pair. Valid events come from PING.smuggle_capabilities; for example body:onpageshow and svg:onload are distinct supported pairs. The discovery alias pageshow resolves to body:onpageshow. Built-in event sets are closed, but a custom trigger_event is accepted when it is a bounded safe event token and trigger_method is one of the registered canonical element tokens in custom_trigger_methods. A custom event only attaches the handler to that generated element: the server does not synthesize or dispatch the event and does not accept raw HTML or JavaScript, so the event may never fire unless normal browser or user behavior produces it. Custom input may include or omit the leading on; the response uses the normalized on... form. After that prefix the token starts with an ASCII letter and contains only lowercase ASCII letters, digits, _, or -, with a total normalized limit of 64 characters.
  • output_format: outer artifact format; one of html, htm, shtml, shtm, xhtml, xht, xhtm, xml, or svg. No additional output formats are introduced by the expanded constructor options.
  • download_variant: constructor download implementation; one of blob-anchor, data-uri, iframe-blob, filereader, fetch-blob, window-open, loc-assign, form-post, timeout-blob, promise-blob, raf-blob, microtask-blob, observer-blob, response-blob, readable-stream, message-channel-blob, or idle-callback-blob.
  • page_template: constructor page shell; one of default, minimal, corporate, drive, npf-zip-archive-help, or npf-rar-archive-help.
  • mime_type: constructor-only extracted-file Blob/data URI MIME metadata. Clients may use a value from mime_presets/mime_by_extension or submit a validated custom MIME type. It does not inspect, validate, or convert source bytes, and it is not a simple-mode option.
  • null_byte: 1 or 0 to prepend a leading NUL byte before the generated outer artifact bytes.

loc-assign applies the selected mime_type to its data: URL, but it cannot force the normalized download name because no download attribute participates in that navigation. For this variant the browser chooses any saved filename and the response reports downloadNameApplied=false; other current variants report true.

The safe builder remains server-authoritative: it renders only fixed neutral test-artifact shells, keeps the normal one-shot temp-file lifecycle, and does not allow arbitrary HTML, CSS, JavaScript, external redirects, or custom assets. Constructor mode is incompatible with legacy encrypt=1; XOR in the constructor payload-encoding list is only obfuscation of the embedded payload. The title, message, cta_label, and show_notice values affect the generated shell where the selected mode/template supports them. The legacy npf-rar-archive-help token remains accepted for compatibility, but clients should treat it as neutral archive-instructions copy; it does not imply a RAR conversion or content check.

With safe builder parameters:

SMUGGLE /uploads/report.bin?download_name=Quarterly-Report&download_ext=pdf&preset=card_auto&title=Quarterly%20Report&message=Internal%20controlled%20test%20file&cta_label=Download%20test%20artifact&delay_ms=1200&show_notice=1 HTTP/1.1

Response (200):

{
  "url": "/uploads/smuggle_0123abcd4567ef89.html",
  "file": "report.bin",
  "encrypted": false,
  "downloadName": "Quarterly-Report.pdf",
  "downloadNameApplied": true,
  "effectiveMode": "simple",
  "effectivePreset": "card_auto",
  "noticeShown": true,
  "locale": "ru",
  "outputFormat": "html",
  "payloadEncoding": "b64",
  "triggerMethod": "legacy",
  "triggerEvent": "legacy",
  "triggerEventCustom": false,
  "downloadVariant": "blob-anchor",
  "pageTemplate": "legacy",
  "mimeType": "application/octet-stream",
  "nullByte": false
}

triggerEventCustom is true only when constructor mode accepted a custom event token rather than one of the selected method's built-in events. It is false for built-in events and non-constructor responses.

Headers: Content-Type: application/json, X-Smuggle-URL

X-Smuggle-URL contains the same relative path as the JSON url field. A follow-up request to that path returns the one-shot artifact with the file data embedded in the selected payload format. The first GET, HEAD, or matching conditional request consumes and deletes the temporary artifact; browser preloads, link scanners, or manual HEAD checks can therefore consume it before the intended user opens it. Regenerating creates another independent one-shot URL and does not invalidate any older URL that has not yet been consumed, expired, or pruned. The server also prunes retained temporary artifacts by age, count, and generated artifact bytes before admitting a new SMUGGLE artifact; leftover temporary artifacts are cleaned up when the server starts.

SMUGGLE source files are capped before HTML generation. The effective cap is the lower of the SMUGGLE source cap (10 MiB by default) and the configured upload limit. Generated-page retention failures return 507 JSON errors and do not publish a temporary URL.

Too large response (413):

{
  "code": "smuggle_source_too_large",
  "field": "source",
  "error": "SMUGGLE source too large. Max size: 10.0 MB",
  "status": 413,
  "file_size": 10485761,
  "file_size_human": "10.0 MB",
  "max_size": 10485760,
  "max_size_human": "10.0 MB"
}

Invalid safe-builder parameters, unsafe/overlong custom suffixes or events, and explicit use_constructor=0 conflicts return 400 JSON errors such as {"code": "invalid_smuggle_extension", "field": "download_ext", "error": "Invalid SMUGGLE builder extension", "status": 400}. Current SMUGGLE code tokens are invalid_smuggle_locale, invalid_smuggle_extension, invalid_smuggle_preset, invalid_smuggle_payload_encoding, invalid_smuggle_trigger_method, invalid_smuggle_trigger_event, invalid_smuggle_output_format, invalid_smuggle_download_variant, invalid_smuggle_page_template, invalid_smuggle_delay, invalid_smuggle_show_notice, invalid_smuggle_null_byte, invalid_smuggle_use_constructor, invalid_smuggle_mime_type, invalid_smuggle_configuration, smuggle_field_too_long, smuggle_source_not_found, smuggle_source_too_large, and smuggle_temp_quota_exceeded. Clients should still render the human error text for operators.

Status codes: 200 OK, 400 Invalid builder params, 404 File not found, 413 Source too large, 507 Temp storage budget exhausted


NOTE

Secure Notepad with client-side encrypted note blobs. Clients derive an AES-256-GCM key via ECDH and the server stores only opaque encrypted data plus note metadata. This flow uses the runtime cryptography dependency and fails closed with 501 if the crypto backend is unavailable. Notes are stored in the separate top-level notes/ directory as <id>.enc + <id>.meta.json pairs, alongside uploads/ rather than inside it.

The note body field data is encrypted client-side and stored as an opaque base64 blob. Note IDs, titles, timestamps, sizes, and the optional session marker are plaintext metadata visible to the server and to any operator who can read notes/*.meta.json.

Server-generated note IDs are 32 lowercase hex characters. For legacy v0 compatibility, existing-note paths and client-supplied save IDs currently accept lowercase hex IDs from 1 to 32 characters. New clients should send 32-character lowercase hex IDs if they need client-generated IDs.

Current note keys are session-bound, not durably recoverable. The browser UI and examples/notepad_client.py keep the derived AES key only in process memory. Reloading the page, restarting the client, server restart, idle session expiry, or LRU session eviction can leave previously saved note bodies undecryptable by that client. The server does not persist note encryption keys and exposes no API to decrypt or re-key stored note blobs.

ADR-009 treats this as an intentional product/security boundary: stored ciphertext plus metadata are not sufficient for durable recovery, and the current HTTP/WebSocket Notepad flow is not a backup or multi-device sync system.

Notepad save requests have a Notepad-specific encrypted blob limit: data must decode to at most 1 MiB (1,048,576 bytes), which is at most 1,398,104 base64 characters. Aggregate encrypted blobs are also capped by --note-storage-limit MB and --note-count-limit N before any note temp files are created. This application limit is enforced for both NOTE /notes and WebSocket save messages, independent of generic transport caps such as HTTP --max-size and the WebSocket frame limit. A lower transport cap can still reject the request before Notepad validation runs. Per-note over-limit saves return 413; aggregate quota failures return 507. Both HTTP and WebSocket errors leave existing note files unchanged and do not write partial note state.

NOTE /notes/key

Get the server's ECDH public key.

Request:

NOTE /notes/key HTTP/1.1

Response (200):

{
  "hasEcdh": true,
  "publicKey": "<base64 of 65-byte uncompressed P-256 point>"
}

If the crypto backend is unavailable, hasEcdh is false and publicKey is absent. In that mode, save/load/list/delete NOTE operations are unavailable and return 501.


NOTE /notes/exchange

Exchange ECDH keys to establish a session. The client sends its ephemeral P-256 public key; the server returns a short-lived sessionId and its own public key. Both sides independently derive the same AES-256-GCM session key via HKDF-SHA256.

Exact derivation contract:

Parameter Value
Curve ECDH P-256 (secp256r1)
Public key encoding 65-byte uncompressed X9.62 point, base64 encoded on the wire
HKDF hash SHA-256
HKDF output length 32 bytes
HKDF salt 32 zero bytes (00 repeated 32 times)
HKDF info UTF-8 bytes for notepad-e2e-key
Content cipher AES-256-GCM
Encrypted blob format nonce(12) + ciphertext + tag(16), then base64 encoded as data

Request:

NOTE /notes/exchange HTTP/1.1
Content-Type: application/json

{"clientPublicKey": "<base64 of 65-byte uncompressed P-256 point>"}

Response (200):

{
  "sessionId": "<32-char hex>",
  "serverPublicKey": "<base64 of 65-byte uncompressed P-256 point>",
  "sessionTtlSeconds": 3600
}

sessionId is audit-only server state: if an active session ID is later sent with a save request, the server records that the note write came from a recent ECDH exchange. It is not an authorization token for reads or writes.

The complete session ID is returned only as part of this protocol response and may be sent back by the client. Debug lifecycle messages use a stable sidfp:<12-hex> SHA-256 fingerprint instead of the complete identifier, and unknown-session exceptions use generic text. Operators can correlate lifecycle events without turning logs or error bodies into a source of reusable ephemeral session identifiers.

Status codes: 200 OK, 400 Missing/invalid key, 501 ECDH unavailable


NOTE /notes — list notes

Request:

NOTE /notes HTTP/1.1

Response (200):

{
  "notes": [
    {
      "id": "<32-char hex>",
      "title": "My Note",
      "created_at": "2025-01-15T10:30:00+00:00",
      "updated_at": "2025-01-15T10:30:00+00:00",
      "size": 256
    }
  ],
  "count": 1,
  "limit": 1000,
  "truncated": false
}

Listing work is bounded by the configured list limit, which follows --note-count-limit by default and falls back to 1000 when note count quota is disabled.


NOTE /notes — save note

Send a JSON body to create or update a note. data must be a base64-encoded AES-256-GCM ciphertext (encrypted client-side). Include id to update an existing note; omit to create a new one. Clients that need idempotent create/retry behavior can send a valid hex id with createIfMissing: true; the server creates that note when missing and updates the same note on retry. Without createIfMissing, updating a missing id still returns 404. The encrypted blob is the source of truth, so malformed metadata sidecars are ignored or rebuilt as needed.

Request:

NOTE /notes HTTP/1.1
Content-Type: application/json
X-Session-Id: <sessionId>   (optional, audit-only; ignored when expired)

{
  "title": "My Note",
  "data": "<base64-encoded encrypted blob>",
  "id": "<32-char hex>",        (omit for server-generated new note)
  "createIfMissing": true       (optional; only meaningful with id)
}

Response (201 for new, 200 for update):

{
  "success": true,
  "id": "<32-char hex>",
  "title": "My Note",
  "created_at": "2025-01-15T10:30:00+00:00",
  "updated_at": "2025-01-15T10:30:00+00:00",
  "size": 256
}

Status codes: 201 Created, 200 Updated, 400 Bad request, 404 Note not found (for update), 413 Encrypted note data too large, 507 Notepad aggregate quota exceeded, 501 Secure Notepad crypto backend unavailable


NOTE /notes/{id} — load note

Request:

NOTE /notes/a1b2c3d4... HTTP/1.1

Response (200):

{
  "id": "<32-char hex>",
  "title": "My Note",
  "data": "<base64-encoded encrypted blob>",
  "created_at": "2025-01-15T10:30:00+00:00",
  "updated_at": "2025-01-15T10:30:00+00:00",
  "size": 256
}

Status codes: 200 OK, 404 Not Found, 501 Secure Notepad crypto backend unavailable


NOTE /notes/{id}?delete — delete note

Request:

NOTE /notes/a1b2c3d4...?delete HTTP/1.1

Response (200):

{
  "success": true,
  "id": "<32-char hex>"
}

Status codes: 200 OK, 404 Not Found, 501 Secure Notepad crypto backend unavailable


NOTE /notes?clear=1 — clear all notes

Deletes all user-visible entries from the separate notes/ directory. Files in uploads/ are not touched.

Request:

NOTE /notes?clear=1 HTTP/1.1

Response (200):

{
  "success": true,
  "cleared": true,
  "path": "/notes",
  "deleted_files": 4,
  "deleted_dirs": 0,
  "preserved": []
}

Hidden files inside notes/ are preserved.

Status codes: 200 OK, 500 Clear failed, 501 Secure Notepad crypto backend unavailable


WebSocket — /notes/ws

Legacy Notepad transport over WebSocket (RFC 6455). It is not a durable workspace-sync protocol: the server does not provide revision checks, conflict detection, replay, resume tokens, or collaborative merge. The server detects an upgrade request on any path starting with /notes/ws and performs the handshake inline, before the normal HTTP handler runs. The connection uses a 60-second idle timeout; the server sends a ping frame when idle to keep the connection alive. Active WebSocket connections are admitted against --max-websocket-connections (--workers // 2 by default), and incomplete frames must finish within --websocket-frame-idle-timeout (5 seconds by default). Upgrade validation requires GET, Host, Upgrade: websocket, Connection: Upgrade, a valid 16-byte Sec-WebSocket-Key, and Sec-WebSocket-Version: 13. When the server is running without the crypto backend, the upgrade is rejected with 501.

Upgrade request:

GET /notes/ws HTTP/1.1
Host: <server-host>
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <base64-nonce>
Sec-WebSocket-Version: 13

Response:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <computed accept key>

All WebSocket messages are UTF-8 JSON text frames. The client sends masked frames (required by RFC 6455); the server sends unmasked frames. Binary data frames are not part of the legacy contract and are closed before dispatch with WebSocket close code 1003 (Binary frames are not supported).

Same-origin upgrades are allowed by default. Cross-origin upgrades require the Origin to match an exact configured --cors-origin value. Wildcard --cors-origin * is read-only CORS and does not authorize WebSocket upgrades.

Client message types:

type Fields Description
save title, data, noteId?, createIfMissing?, sessionId?, opId? Save a note (sessionId is optional audit-only state; opId is echoed for acknowledgement correlation)
load id Load a note by ID
list List all notes
delete id Delete a note while the always-on NOTE/WebSocket surface is available
clear Clear all notes from the separate notes/ directory while the always-on NOTE/WebSocket surface is available

Server response types:

type Fields Description
saved success, id, title, opId?, ... Note saved
loaded id, title, data, ... Note loaded
list notes, count Note list
deleted success, id Note deleted
cleared success, deletion counters, preserved Notes cleared
error error Error message

Domain errors for save, load, delete, and clear can keep the operation response type (saved, loaded, deleted, or cleared) while adding error and status. Invalid JSON, non-object messages, unknown message types, and invalid note IDs use {"type": "error", "error": "..."}. delete and clear are part of the same always-on NOTE/WebSocket method surface as save, load, and list. They remain subject to the normal authentication, origin, input-validation, and storage-safety checks; there is no separate note_delete or note_clear runtime capability. Unexpected internal WebSocket failures are logged as errors, counted in metrics.websocket.errors, and closed with WebSocket code 1011 rather than normal close code 1000. If a JSON response cannot be sent, the send failure is logged, the connection is treated as failed, and the server attempts to close with 1011; clients should treat the operation outcome as unknown and verify state after reconnect.

For idempotent WebSocket saves, send opId so the client can correlate the saved acknowledgement and send a stable hex noteId with createIfMissing: true for first saves that may be retried after reconnect or HTTP fallback. Retrying the same noteId updates the original note instead of creating a duplicate. The server does not persist or deduplicate opId; it is only copied into the corresponding saved response when present. There is no server-side conflict contract for concurrent writes: repeated writes to the same note ID use last-write-wins semantics at the encrypted-blob level.


OPTIONS

CORS preflight handler. Returns allowed methods when CORS is enabled.

Response (204): No body. If CORS is disabled, no Access-Control-Allow-* headers are emitted. If CORS is enabled, Access-Control-Allow-Methods lists the full core method surface for exact origins. For wildcard --cors-origin *, preflight stays read-only and lists only read methods. A requested unknown method is added only for exact-origin advanced-upload preflights when it is a valid HTTP token. Requested headers are reflected only when they are in the server allowlist (Authorization, Content-Type, If-None-Match, X-File-Name, X-Session-Id, X-XFerry-No-Gzip, X-Exphttp-No-Gzip, X-D, X-E, X-K, X-Kb64, X-N, X-H, X-Encoding, X-HTTP-Method-Override, X-Payload-In-Path, and numeric X-D-N chunk headers).


Advanced Upload

Advanced upload is part of the always-on full method surface. Unknown, non-standard HTTP methods carrying advanced upload data are accepted by the advanced upload handler. Unknown methods without an advanced payload return shared JSON 405. Writes are still limited to uploads/.

Advanced routing control API

/_xferry/advanced-routing is an exact, service-owned control route:

Method Behavior
GET Return current state
PUT Validate and atomically replace the complete state
PATCH Validate and atomically replace only diagnostic_headers
DELETE Disable prefix routing and reset the decoder while preserving diagnostics

The response state always has exactly:

{
  "prefix": null,
  "decoder": "auto",
  "diagnostic_headers": false,
  "revision": 0
}

State is process-local, independent per server instance, never persisted, and resets to the values above on restart. revision increments only when the effective configuration changes; idempotent PUT/PATCH/DELETE leaves it unchanged. DELETE sets prefix to null and decoder to auto, preserving the current diagnostic_headers value. Responses use Cache-Control: no-store.

PUT requires exactly all three input keys:

PUT /_xferry/advanced-routing HTTP/1.1
Content-Type: application/json

{"prefix":"/route","decoder":"auto","diagnostic_headers":false}

PATCH requires exactly the diagnostics field and never changes the route prefix or decoder:

PATCH /_xferry/advanced-routing HTTP/1.1
Content-Type: application/json

{"diagnostic_headers":true}

Prefixes are absolute and case-sensitive. Matching requires an exact path or a slash-delimited descendant: /route matches /route and /route/file, not /route-file. / is valid and matches every absolute path. Prefix validation rejects query/fragment/percent/backslash/control characters, empty, . or .. segments, trailing slash except /, and the reserved /_xferry namespace.

Routing precedence is exact control route > matching standard upload prefix > registered core/plugin dispatch > unknown custom-method Advanced fallback. Only POST, PUT, PATCH, and NONE participate in prefix matching. Once one of those methods matches, all decoder/validation errors are Advanced responses; there is no Basic fallback. Unknown custom methods with an Advanced payload continue to use the legacy fallback whether prefix routing is enabled or disabled.

Supported decoder modes are auto, raw, json, text, form, xml, and multipart. For prefix-dispatched requests, auto selects from request MIME and treats application/octet-stream as byte-stable raw data. The configured decoder applies only to prefix dispatch for POST, PUT, PATCH, and NONE. In that prefix path, a fixed decoder overrides MIME; malformed or mismatched input fails that decoder instead of silently switching. Unknown custom-method fallback ignores the configured decoder and retains legacy MIME-derived behavior.

Activating routing returns 409 if a configured plugin owns any of POST/PUT/PATCH/NONE; the exact control route cannot be overridden by root prefix or a plugin. Control authorization is deliberately narrower than general CORS:

  • unauthenticated non-browser requests require a direct loopback peer;
  • an authenticated non-browser request may be remote;
  • any browser request with Origin must be same-origin; a configured CORS origin does not grant control access;
  • forwarded client-IP headers do not satisfy loopback;
  • PUT/PATCH/DELETE still pass the normal browser mutation guard after this control-specific check.

Diagnostics

Basic and Advanced upload JSON diagnostics are always on. Setting diagnostic_headers: true adds exactly these six response mirrors:

  • X-XFerry-Handler
  • X-Upload-Profile
  • X-File-Name-Source
  • X-File-SHA256
  • X-Request-Body-Size
  • X-XFerry-Route-Revision

The default is off and the client controls it through the API; there are no explicit request tags that opt an individual request into Advanced routing.

The advanced upload endpoint accepts payloads through a small matrix of carriers and formats:

  • body: JSON, raw binary fallback, text/plain, application/x-www-form-urlencoded, multipart/form-data, XML/SOAP
  • headers: X-D or numeric chunks X-D-0, X-D-1, ...
  • query: ?d=...
  • cookies: xf_d, plus xf_* metadata cookies
  • path: final path segment when path_payload=1 or X-Payload-In-Path: 1 is present; path_filename=1 treats the previous segment as the filename

Common fields are:

  • d / data: encoded payload
  • e: encryption mode
  • k: decryption key
  • kb64: whether k is base64-encoded
  • n / name: suggested filename; omitted names are generated as <sha256[:12]>.bin
  • h / hmac: integrity tag
  • encoding / enc: base64, base64url, hex, percent, gzip-base64, or raw
  • _method / method_override: method-override metadata; the receive layer does not reinterpret the actual HTTP method

For multipart-binary body uploads, the file-part filename is the authoritative saved-name candidate and filename_source is part. Optional filename copies in multipart n / name fields, X-N, query n / name, xf_n / xf_name cookies, or path_filename=1 path metadata are accepted only when every supplied copy exactly matches the file-part filename before sanitization. Any conflict returns 400 and no file is written. multipart-encoded continues to use d / data as the encoded payload and n / name as filename metadata.

Form/query/JSON/XML split fields are accepted as d0, d1, ... (also d-0, d_0, data0, data-0, data_0). Header transport also supports chunked payload headers X-D-0, X-D-1, ... for long values.

Advanced upload applies explicit caps before writing. Encoded carriers are admitted before payload decoding; decoded payloads are capped during gzip decompression and after other decoders or decryption, before any publish. The HTTP receive layer still buffers accepted request bodies and enforces the global --max-size cap before dispatch.

  • Decoded advanced-upload payloads are limited to 16 MB by default and never exceed --max-size.
  • JSON body requests larger than the encoded payload cap plus a 4 KB JSON envelope allowance are rejected before UTF-8 decoding or JSON parsing. JSON bodies inside that envelope are parsed, then d / data strings are checked against the exact encoded-size cap before payload decoding.
  • gzip-base64 and gzip-base64url output is decompressed incrementally with the decoded-payload cap enforced during decompression. Expansion beyond the cap returns 413; invalid gzip data remains a 400 invalid-encoding response.
  • Header transport X-D and combined X-D-0, X-D-1, ... data are limited to 64 KB of encoded data by default.
  • URL query transport ?d= is limited to 16 KB of encoded data by default.
  • Cookie payload data shares the header encoded-data cap. Marked path payload data shares the URL encoded-data cap.
  • Transfer-Encoding: chunked request bodies remain unsupported and are rejected before dispatch; use split fields or header chunks instead.

Over-limit advanced-upload requests, including gzip expansion beyond the decoded cap, return 413 JSON errors and do not write files. Larger uploads should use standard POST, PUT, PATCH, or NONE body uploads, which are governed by --max-size per request and the optional aggregate upload storage policy before publish.

The browser UI is profile-first: Managed presets configure a coherent request, while Experimental permits explicit changes without silently rewriting the selected carrier/profile. Multipart uses browser-managed FormData; its top-level multipart/form-data boundary and Content-Length are authoritative, so a conflicting declared MIME cannot be sent. A fixed decoder/profile mismatch is reported before send. Filename placement has one primary location plus optional exact copies, and file size produces warnings/recommendations only—there is no automatic profile switch.

Browser Fetch cannot issue CONNECT, TRACE, or TRACK, or directly set forbidden headers such as Host, Cookie, and Content-Length. Cookie profiles therefore write document.cookie; multipart length/boundary remain browser-managed. The UI models browser ceilings of 2 GiB for body, 64 KiB for headers, 8 KiB for query/path, and 4 KiB for cookies alongside the server caps above and the global request cap.

Resumable Content-Range uploads and protocol-specific HTTP/2 or HTTP/3 behavior are out of scope. A reverse proxy may terminate HTTP/2 externally, but xferry's documented application/request-framing contract remains HTTP/1.1.

JSON body example:

Request:

CHECKDATA / HTTP/1.1
Content-Type: application/json

{"d": "SGVsbG8=", "n": "hello.txt"}

Response (200):

{
  "ok": true,
  "id": "a1b2c3d4e5f67890",
  "sz": 5,
  "transport": "body"
}

The response does not include the final saved filename or /uploads/... path. If n / name is omitted, the server uses <sha256[:12]>.bin. Before writing, the chosen name is sanitized and, if it already exists, receives a random suffix. Clients that need a stable follow-up path should provide a filename and can confirm the saved workspace contents with INFO.

Header transport example:

Request:

CHECKDATA /filename HTTP/1.1
X-D: <base64-payload>
X-E: xor
X-K: <password>
X-Kb64: 0
X-N: file.bin
X-H: <hmac-sha256-hex>

<optional raw body if not using structured fields>


Authentication

When --auth is enabled, all requests require HTTP Basic Auth:

Authorization: Basic <base64(user:pass)>

Failed auth returns 401 with WWW-Authenticate header. Rate limiting applies after 5 failures (30s cooldown, 429 response).

The limiter keys on the direct TCP peer IP from the accepted socket. In proxied deployments, 401/429 semantics therefore reflect the proxy connection unless the proxy enforces per-client throttling first. Forwarded, X-Forwarded-For, and similar headers are not trusted as client identity; see docs/ADR/ADR-008-trusted-proxy-client-identity-boundary.md.


Browser-Origin Mutation Guard

State-changing HTTP requests from browsers are accepted only when they are same-origin or explicitly allowed by --cors-origin. Protected methods are POST, PUT, PATCH, DELETE, NONE, NOTE, SMUGGLE, plus unknown methods that carry advanced-upload data.

Requests with an Origin header must match the request host/scheme or a configured CORS origin. Sec-Fetch-Site: cross-site and same-site requests without Origin are rejected; with Origin, they require a configured CORS origin. Non-browser API clients that omit both Origin and Sec-Fetch-Site keep the existing behavior. Wildcard --cors-origin * still emits read CORS headers, but does not authorize browser mutations from arbitrary origins.

Rejected browser-origin mutations return 403 JSON:

{"error": "Forbidden cross-origin browser mutation", "status": 403}

Common Headers

Header Description
X-Request-Id Unique request correlation ID on normally dispatched HTTP responses; direct guard or upgrade errors may be sent before this decoration
X-Upload-Status Upload result: success, error, no-data
X-Fetch-Status Download result: success, file-not-found
X-File-Name Sanitized filename
X-File-Size File size in bytes
X-File-Path Path to uploaded file
X-Ping-Response Ping result (pong)
X-XFerry-Handler Optional upload dispatch mirror: basic or advanced
X-Upload-Profile Optional decoded upload profile mirror
X-File-Name-Source Optional filename-source mirror
X-File-SHA256 Optional final-payload SHA-256 mirror
X-Request-Body-Size Optional received body-byte-count mirror
X-XFerry-Route-Revision Optional process-local routing revision mirror
X-XFerry-No-Gzip Request opt-out for HTTP response gzip compression (1); the canonical client header
X-Exphttp-No-Gzip Legacy request alias for X-XFerry-No-Gzip