Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [Unreleased]

- Security (TC-1132, TC-1080 Parts C–D): webhook registration and every delivery attempt share an egress policy covering special-use IPv4/IPv6, embedded IPv4 (mapped, compatible, NAT64 and 6to4), Teredo and site-local IPv6. Ambiguous local-use NAT64 is refused if any valid IPv4 interpretation is forbidden. Corrected public controls such as `192.2.1.1` and `198.51.0.1` are no longer over-blocked. Mixed DNS answers retain only permitted addresses.
- Webhooks deny the resolved addresses of the configured network database, quota URL, space allowlist and explicit S3 endpoint on every port, including aliases and embedded IPv4 forms. The service deny-set refreshes every ten minutes; validation waits for an in-progress refresh, and failed/stale resolution closes egress until recovery. Temporary DNS/policy failures retry on the existing bounded schedule; invalid or forbidden callbacks dead-letter. Existing subscriptions whose destinations become forbidden dead-letter on the next attempt.
- Webhook DNS is bounded to one second. Each delivery resolves once and gives only vetted addresses to reqwest through a sealed, immutable-target sender. A fresh policy lease rechecks literal and hostname addresses and remains held through connection/send, so refresh cannot invalidate a vetted target before it connects. Proxy environment variables are ignored, redirects/non-HTTPS destinations refused, connect time capped at two seconds, and the configured total timeout includes lease/freshness work and sending. No production private-destination/development flag was added; body bytes, headers and HMAC signing are unchanged.
- Registration caps both raw and canonical callback URLs at 2,048 bytes and secrets at 256 bytes. Retry attempts 6 and later keep the one-hour backoff; `hooks.webhook_max_attempts` must not exceed 10.
- Quota requests encode the original SpaceId as one path segment and do not follow redirects; cache/override keys and fallback remain unchanged. Tunnel upstream requests do not follow redirects and have a 30-second total timeout, including body reads; 3xx responses and existing per-request errors retain their framing.
- Network DID methods remain disabled by default. Full TC-1080 Part B (node-owned did:web fetcher, destination policy, timeouts, body limits, and refusal tests) is **required before any future re-enable**; this change does not implement or enable that fetcher. No schema migration.

## [1.20.6] - 2026-10-14

A security fix (TC-1080, Part A). UCAN issuer resolution no longer dereferences network DID methods by default, so a request can't make the node fetch arbitrary or internal hosts through a crafted issuer DID (#284).
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ containing only `storage_limit_bytes` remain supported. Local testing can
set an in-memory limit with authenticated
`PUT /admin/quota/<space>` and `{"limit_bytes":1}`; overrides reset on restart.

The billing request percent-encodes the original space ID as one path segment;
the quota/cache key itself is unchanged. The client never follows redirects,
so the service bearer is sent only on the configured billing request. A `3xx`
answer uses the existing quota fallback policy, with the existing 3-second total
and 2-second connect deadlines.

### Signed KV URLs

`POST /signed/kv` accepts a normal TinyCloud invocation whose authorized capabilities include a `tinycloud.kv/get` capability that can be attenuated to the requested `{space, path}`. The invocation may include other capabilities. The JSON body is:
Expand Down Expand Up @@ -311,12 +317,18 @@ setting. DID parsing and string comparisons, including share-email `did:web`
identifiers, are unchanged. Existing route status codes and policy machine codes
are preserved.

Configure `[global.did_resolution] network_methods = false` in `tinycloud.toml`
or use the canonical environment variable
`TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS`. For rollback without rebuilding,
set `TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS=true` and restart the node.
This restores the previous methods with the unhardened SSI client, while issuer
queries remain refused. The previous image is the fallback rollback.
Keep `[global.did_resolution] network_methods = false` in `tinycloud.toml`
or the canonical environment variable
`TINYCLOUD_DID_RESOLUTION__NETWORK_METHODS=false`. Network DID methods, including
`did:web`, remain disabled under the current policy.

**Full TC-1080 Part B hardening is REQUIRED before any future re-enable**,
including setting the existing `network_methods` option to `true`. The
unhardened SSI client is not an acceptable rollback path. Part B is not
implemented here, and there is no Part B development bypass. The required
node-side fetcher, destination enforcement, resource limits and local-listener
acceptance checks are described in the
[developer gate](docs/delegation-flow.md#network-did-method-future-enable-gate-tc-1132).

The startup log records the effective policy. Refusals increment
`tinycloud_did_resolution_refused_total{method,reason}` (even when latency
Expand Down
38 changes: 38 additions & 0 deletions docs/delegation-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,44 @@ The delegation hierarchy enables:
| Session Key | `did:key:z6Mk...#z6Mk...` | `did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK` |
| PKH (Wallet) | `did:pkh:eip155:{chainId}:{address}` | `did:pkh:eip155:1:0x742d35Cc6634C0532925a3b844Bc9e7595f1Ac8b` |

### Network DID method future-enable gate (TC-1132)

The node's UCAN issuer resolver currently permits only offline `did:key`,
`did:pkh` and `did:jwk` resolution by default. Network methods, including
`did:web`, remain disabled with `network_methods = false`. DID identifiers used
for parsing or identity comparisons (for example share-email `did:web` values)
are unaffected; this gate concerns network resolution during verification.

**Full TC-1080 plan Part B is REQUIRED before any future re-enable**, including
the existing `network_methods = true` setting. Do not restore the unhardened SSI
fetcher as a rollback. Part B is not implemented by TC-1132 Parts C/D, and no
Part B development flag or bypass is provided.

Before a future change enables `did:web`, it must implement and verify all of
Part B:

- A node-owned DID-web fetcher behind the node resolver, not an SSI-fork change;
absent a hardened fetcher, network resolution must stay refused.
- Strict DID-to-HTTPS URL construction and document identity checks, with
IP-literal hosts, unsafe path segments, unexpected ports and issuer queries
refused.
- Destination policy enforced on the exact DNS addresses used to connect:
forbidden IPv4/IPv6 classes (including embedded IPv4), metadata/link-local
destinations and the node's database/internal endpoint deny-set. No second
unchecked DNS lookup, environment-proxy bypass or redirect following.
- Bounded DNS, connect and total request time; status/content-type checks; a
64 KiB response cap; concurrency and per-host rate limits; bounded positive
and negative caches with in-flight deduplication; count-only refusal evidence.
- Local-listener and injected-resolver regressions for every refused address
class, database/internal destinations, URL construction, ignored proxies,
DNS rebinding, redirects, oversized bodies, timeouts and rate/cache behavior.
Retain Part A refusal checks and prove a permitted DID document verifies
end-to-end only after the hardened fetcher is installed.

Any development exception described by the original Part B plan is itself
subject to this gate; it must never exempt metadata/link-local addresses or
the internal endpoint deny-set. This documentation does not enable one.

### Space ID Format

Spaces are identified using the format:
Expand Down
5 changes: 5 additions & 0 deletions docs/specs/node-control-plane-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,11 @@ Connection lifecycle (owned by `serve`, not the CLI):
response streamed back as `response` + `responseBody...` frames. A
per-request failure sends an `error` frame for that request's `id` only —
it never closes the socket.
- The loopback HTTP client never follows redirects. A `3xx` response, its
`Location` header and body are returned through the ordinary response frames;
the node makes no request to the redirect target. Each upstream request has
a 30-second total deadline, including response-body reads. A timeout uses the
existing per-request `error` frame and leaves the tunnel socket open.
- The relay pings every 30s; the underlying WebSocket library answers pings
automatically. If no frame (data, ping, or close) arrives for an extended
idle window, the node treats the connection as dead and reconnects.
Expand Down
24 changes: 24 additions & 0 deletions tinycloud-node-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,16 @@ impl Default for HooksConfig {
}
}

impl HooksConfig {
/// Reject retry budgets outside the supported bound rather than silently truncating.
pub fn validate(&self) -> Result<(), &'static str> {
if self.webhook_max_attempts > 10 {
return Err("hooks.webhook_max_attempts must not exceed 10");
}
Ok(())
}
}

/// Retention sweep policy for terminal `hook_delivery` rows and expired
/// `signed_kv_ticket` rows (TC-287).
///
Expand Down Expand Up @@ -1425,6 +1435,20 @@ mod tests {
use std::io::Write;
use tempfile::NamedTempFile;

#[test]
fn tc1132_startup_validates_retry_budget_bounds() {
for attempts in [0, 1, 10] {
let mut config = Config::default();
config.hooks.webhook_max_attempts = attempts;
assert!(crate::resolve_runtime_config(&config).is_ok());
}
for attempts in [11, usize::MAX] {
let mut config = Config::default();
config.hooks.webhook_max_attempts = attempts;
assert!(crate::resolve_runtime_config(&config).is_err());
}
}

fn enabled_config() -> ShareEmailConfig {
ShareEmailConfig {
enabled: true,
Expand Down
Loading
Loading