Appearance
Shell Resource Policy Checklist
Kehto host-hardening guide for draft NAP-RESOURCE consumers. The pinned upstream draft, not this checklist, defines wire messages and fields.
Status
Draft NAP-RESOURCE at fa6bcc6935aa19e7b70ab2a2c721dafca77c78e1 is the working protocol authority. This non-normative checklist explains how Kehto hosts satisfy its security requirements. Paja currently enables only a locally decoded data: backend; network schemes stay disabled until the corresponding host boundary meets this checklist.
Why this exists
Profile-media byte retrieval makes the host shell a network-fetch path on behalf of sandboxed napplets. The shell-as-fetch-proxy model is an irreducible attack surface: a naively-implemented shell becomes an SSRF gadget that can probe internal addresses, exfiltrate cloud-metadata credentials, or scan the deployer's intranet on behalf of an attacker-supplied URL. This checklist makes Kehto's operator-visible hardening decisions explicit.
Shells that advertise a network scheme without meeting the mandatory hardening requirements below are not NAP-RESOURCE conformant.
Private-IP Block List (MUST, at DNS-resolution time)
The single most important policy. Reject URLs whose resolved IP falls in any blocked range. Check happens after the DNS resolver returns an address and before the HTTP connection is opened. Each redirect hop is re-validated independently.
URL-parse-time checks (looking at the literal hostname) are NOT sufficient — an attacker-controlled DNS record can resolve attacker.example.com to 127.0.0.1 and bypass any naive check. DNS pinning to the validated address defeats DNS-rebinding and TOCTOU attacks.
Required ranges
- [ ]
10.0.0.0/8— RFC1918 private IPv4 - [ ]
172.16.0.0/12— RFC1918 private IPv4 - [ ]
192.168.0.0/16— RFC1918 private IPv4 - [ ]
127.0.0.0/8— IPv4 loopback - [ ]
::1/128— IPv6 loopback - [ ]
169.254.0.0/16— IPv4 link-local - [ ]
fe80::/10— IPv6 link-local - [ ]
fc00::/7— IPv6 unique-local - [ ]
169.254.169.254(singleton) — Cloud metadata service (AWS, GCP, Azure, DigitalOcean, etc.)
Implementation requirements
- [ ] Validation runs after DNS resolves to an IP, before the TCP connection is opened
- [ ] Each redirect hop is re-validated independently against the same list
- [ ] Failed validation emits
error: "blocked-by-policy"with diagnostic detail identifying the matching range - [ ] Additional addresses MAY be allowed behind explicit shell-administrator policy (enterprise on-prem services), but the default for community-deployed shells MUST be restrictive
Sidecar Pre-Resolution (default OFF)
Where a Kehto host elects to pre-fetch resources referenced by an event, it may maintain an identity-scoped sidecar cache using NAP-RESOURCE's ResourceSidecarEntry contract and a carrier-owned field such as RelayEventResult.sidecar.resources.
Privacy rationale (why default OFF)
Pre-fetching reveals user activity to upstream hosts before the user has chosen to render the event. An avatar URL on every event in a 1000-event timeline becomes 1000 HTTP requests, each one a fingerprint visible to the upstream host operator. Default OFF preserves the user's "I haven't rendered this yet" semantic.
Checklist
- [ ] Sidecar emission defaults to OFF (no pre-resolution unless explicitly opted in)
- [ ] Opt-in is per shell deployment policy — not a per-napplet capability the napplet can negotiate
- [ ] Opt-in SHOULD be scoped by a per-event-kind allowlist (e.g., enable for kind 0 metadata only; do not pre-fetch resources from arbitrary user-content kinds)
- [ ] Sidecar bytes obey the same policy as direct
resource.bytes(url)calls (private-IP block, MIME byte-sniffing, size cap, SVG rasterization) - [ ] The
mimefield on each sidecar entry is shell-classified by byte-sniffing — never populated from the upstreamContent-Typeheader - [ ] SVG entries appearing in a sidecar are rasterized to PNG/WebP before being placed on the wire (the sidecar is not a bypass for SVG rasterization)
- [ ] Operators document any deviation from default-OFF in the shell's user-facing privacy notice
SVG Rasterization Caps (MUST)
image/svg+xml is a parseable XML execution surface — <script>, <foreignObject>, <image href> external references, <use href> recursion, DOCTYPE entity expansion (the "billion laughs" pattern), @font-face src: URLs. Delivering raw SVG bytes to a sandboxed napplet recreates every attack surface the sandbox was designed to eliminate.
Required behavior
- [ ] When the byte-sniffer identifies a fetched resource as
image/svg+xml, the shell rasterizes it to a bitmap (PNG or WebP) before delivery - [ ] The
mimefield on the result envelope for any SVG-source-input isimage/pngorimage/webp— neverimage/svg+xml - [ ] The rasterizer runs in a sandboxed Worker with no network access (no
XMLHttpRequest, nofetch, noWebSocket, noEventSource, no<img>externalhrefresolution, no<use href>external resolution, no@font-face src:resolution, no DOCTYPE entity URL resolution)
Recommended caps (SHOULD, all enforced together)
| Cap | Recommended default | On exceed |
|---|---|---|
| Max input bytes | 5 MiB | code: "too-large" |
| Max output dimensions | 4096 × 4096 pixels | code: "too-large" |
| Wall-clock rasterization budget | 2 seconds | code: "timeout" |
All three caps mitigate distinct attacks:
- Input cap → billion-laughs entity expansion is bounded
- Output cap → recursive-
<use>rendering bombs are bounded - Wall-clock cap →
<foreignObject>script-driven CPU exhaustion is bounded
Relaxing any one cap undermines the others.
MIME Byte-Sniffing Allowlist (MUST)
The upstream Content-Type header is attacker-controlled. A content host can declare text/html for what is actually image/png (or vice-versa) and coerce a napplet into a confused-render attack.
Required behavior
- [ ] Classify response bytes via a byte-sniffing strategy (WHATWG MIME Sniffing Standard or equivalent)
- [ ] Never pass through the upstream
Content-Typeheader to the napplet - [ ] Enforce a scheme-appropriate MIME allowlist; bytes whose sniffed MIME falls outside the allowlist are rejected with
error: "blocked-by-policy"
Recommended baseline allowlist (image-rendering shells)
- [ ]
image/png - [ ]
image/jpeg - [ ]
image/webp - [ ]
image/gif - [ ]
image/svg+xml— only acceptable as input to the rasterizer; never delivered to the napplet asimage/svg+xml
Shells delivering non-image bytes (e.g., application/json for nostr: resolution) extend the allowlist per scheme. Maintain one allowlist per scheme rather than a global union — the threat model differs per scheme.
Redirect Chain Limits (SHOULD, with per-hop re-validation)
Public hosts can 302 to internal addresses. Without per-hop re-validation, a redirect chain trivially bypasses the private-IP block list.
Recommended values
- [ ] Cap redirect chain at 5 hops
- [ ] Each hop is re-validated independently against the private-IP block list (DNS pinning per hop)
- [ ] Excess hops or a redirect to a blocked address emits
error: "blocked-by-policy"
Recommended Operational Caps (SHOULD)
These mitigate resource-exhaustion attacks against the shell itself.
| Cap | Recommended default | On exceed |
|---|---|---|
| Per-response size | 10 MiB | error: "too-large" |
| Per-URL fetch timeout (wall-clock) | 30 seconds | error: "timeout" |
Per-napplet concurrent in-flight resource.bytes calls | 10 | error: "blocked-by-policy" |
Per-napplet resource.bytes rate limit (sliding window) | 60 calls / minute | error: "blocked-by-policy" |
| Per-napplet outstanding-Blob quota | ~50 MiB | error: "quota-exceeded" |
Community-deployed shells SHOULD NOT raise the response size cap above ~50 MiB without explicit operator opt-in.
Single-Flight Cache (SHOULD)
Coalesce concurrent same-URL fetches.
- [ ] Cache keyed on the URL string as supplied by the napplet (byte-equal — this NAP does not mandate canonicalization)
- [ ] N concurrent calls for the same URL share one in-flight fetch and resolve with the same
Blobreference - [ ] Cache scope partitioned per
(dTag, aggregateHash)per NIP-5D — napplets MUST NOT see another napplet's cached resources - [ ] Aborted entries are removed from the in-flight map for retryability
Scheme Whitelist (MUST)
Only the canonical schemes plus shell-administrator opt-ins are dispatched. Smuggling-prone schemes are never enabled by default.
Canonical schemes
- [ ]
data:(RFC 2397) — decoded in-shim with zero shell round-trip; size cap still applies on the decodedBlob - [ ]
https:— full Default Resource Policy applies - [ ]
blossom:sha256:<hex>— shell verifies hash against the URL's declared digest before delivery; mismatch →error: "decode-failed" - [ ]
nostr:<bech32>— single-hop NIP-19 resolution; recursive resolution is not the shell's job
Never enable by default
- [ ]
file:— local filesystem read; trivial sandbox escape - [ ]
gopher:,dict:,ftp:,tftp:— protocol smuggling vectors - [ ]
http:(cleartext) — opt-in only behind explicit shell-administrator policy (e.g., enterprise on-prem services)
Unknown schemes emit error: "unsupported-scheme".
Capability Advertisement Boundary
NAP-RESOURCE authorizes the optional resource domain. A host advertises it only when a real service is live. resource.info.schemes reports enabled schemes; it is advisory and never grants authority by itself.
- [ ] Advertise
resourceonly when at least one real, policy-enforced scheme is enabled. - [ ] Keep disabled schemes out of the enabled
resource.infoset and returnunsupported-schemewithout I/O. - [ ] If a host advertises
perm:strict-csp, document that it is a Kehto host policy and independently verify the enforced iframe CSP.
Audit Checklist (one-page summary)
Use this as a deployment sign-off:
- [ ] Private-IP block list enforced at DNS-resolution time, all 9 ranges covered
- [ ] Each redirect hop independently DNS-pinned and re-validated
- [ ] MIME byte-sniffing replaces upstream
Content-Typefor the value delivered to the napplet - [ ] SVG rasterization runs in a sandboxed Worker with no network; raw
image/svg+xmlbytes never reach the napplet - [ ] SVG caps (5 MiB input / 4096×4096 output / 2s wall-clock) all enforced together
- [ ] Sidecar pre-resolution defaults OFF; opt-in per shell deployment policy + per-event-kind allowlist
- [ ] Sidecar bytes obey the same MIME/SVG/size policy as direct calls
- [ ] Single-flight cache scoped per
(dTag, aggregateHash) - [ ] Scheme dispatch is a whitelist; smuggling-prone schemes blocked
- [ ] Resource capability advertisement reflects a live service and truthful scheme disclosure
- [ ] Resource bytes treated as observable (cleartext over postMessage); deployers document this in user-facing notice if relevant
References
- NAP-RESOURCE at exact draft ref
fa6bcc6935aa19e7b70ab2a2c721dafca77c78e1 - Published
@napplet/core0.31.1 /@napplet/nap0.31.2 declarations — released implementation contract, NAP-INTENT authority5ac0490461ca6fec2f0d2e45b4835cf9bc08de24, napplet/web#199 source3037200c932488f14f7f369b8583c39c9c16510a/ mergeb3f0007867eac109fa4917fac9c285d3b7cc6155, and Version Packages #198 release sourcedc1d24153c759152b6ba31a6ec9bea967798f2df - NIP-5D Conformance — napplet-shell protocol alignment; Security Considerations subsection covers strict-CSP posture and
sandbox="allow-scripts"reaffirmation - WHATWG MIME Sniffing Standard — recommended byte-sniffing reference