mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
13
Commits
v1.6.1-rc2
...
v1.6.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
864e572c4f | ||
|
|
a6cd50bc3f | ||
|
|
f3560032d7 | ||
|
|
fb202a06e5 | ||
|
|
c820b5ab3f | ||
|
|
f61a3c3372 | ||
|
|
caf5d2a3b0 | ||
|
|
8ccdd9988f | ||
|
|
0a66749f60 | ||
|
|
a00035b06d | ||
|
|
98a51184c9 | ||
|
|
3c9963426b | ||
|
|
93bbe789fa |
@@ -0,0 +1,48 @@
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
|
||||
# commit on it would put work there that no tag contains, which is exactly the
|
||||
# state that aborted the 1.6.2 cascade at the last step -- so pointing
|
||||
# Dependabot at main would recreate that failure on a schedule.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
# One PR per week for the routine bumps instead of one per crate. Eight
|
||||
# repos times a handful of crates is a volume nobody reads, and an
|
||||
# unread PR queue is indistinguishable from no updates at all.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release.sh as part of the release commit. Dependabot cannot see that
|
||||
# cascade, so a PR bumping one of these would fight the release process
|
||||
# and could pin a version whose tag does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
# The workflows are now real infrastructure -- the release cascade, the
|
||||
# cross-platform hash matrix, the disc gate -- so their actions need the same
|
||||
# attention as the crates.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
+38
-12
@@ -41,15 +41,15 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -78,15 +78,15 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -111,17 +111,30 @@ jobs:
|
||||
# libfreemkv's platform code (SCSI transports included). Build the tests, not
|
||||
# `cargo check` — check skips codegen and never compiles test-only code.
|
||||
check-macos:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -143,17 +156,30 @@ jobs:
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
check-windows:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
|
||||
+10
-10
@@ -43,7 +43,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
@@ -71,15 +71,15 @@ jobs:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
@@ -106,15 +106,15 @@ jobs:
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
@@ -145,15 +145,15 @@ jobs:
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test
|
||||
@@ -58,8 +58,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.3] — 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **The key service is reached over a newer HTTP client, with the same
|
||||
protections.** Before contacting a key service this crate resolves the host,
|
||||
rejects any private or link-local address, and then pins the connection to the
|
||||
addresses it just checked, so a hostname cannot be re-pointed at an internal
|
||||
machine in the moment between the check and the request. That behaviour is
|
||||
unchanged; only the client underneath it moved.
|
||||
|
||||
### Security
|
||||
|
||||
- **The address pinning is now proven by a test rather than assumed.** The checks
|
||||
that reject a bad address were well covered, but nothing verified that the
|
||||
approved addresses were the ones actually connected to — and every existing
|
||||
test still passed with that wiring removed entirely, because none of them
|
||||
opened a connection. A connection made to an unreachable name can only
|
||||
succeed if the pinned address is honoured, and that is now asserted. No
|
||||
defect is being fixed here: the pinning was correct. It simply had no way of
|
||||
telling anyone if it stopped being correct.
|
||||
|
||||
## [1.6.2] — 2026-08-08
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest!
|
||||
|
||||
- **Report a bug** — open an issue
|
||||
- **Fix a bug** — fork, branch, PR
|
||||
- **Suggest a feature** — open an issue first
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.6.1"
|
||||
version = "1.6.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
license = "MIT"
|
||||
@@ -19,13 +19,13 @@ publish = false
|
||||
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
|
||||
libfreemkv = "1.6"
|
||||
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
# KeydbSource::save: decompress a downloaded keydb (.zip / .gz). Same versions
|
||||
# already in the resolved graph via libfreemkv — no new tree, no new HTTP stack.
|
||||
flate2 = "1"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Structural begin/end logging around the keyserver round-trip (never logs
|
||||
# key material).
|
||||
tracing = "0.1"
|
||||
@@ -40,4 +40,4 @@ codegen-units = 1
|
||||
# overrides it with a path patch via the gitignored .cargo/config.toml (a
|
||||
# config-level [patch.crates-io] wins over this manifest one for the same crate).
|
||||
[patch.crates-io]
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.1" }
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.3" }
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.6.x | Yes |
|
||||
| < 1.6 | No |
|
||||
|
||||
Only the current 1.6.x line receives security fixes.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report vulnerabilities privately through GitHub Security Advisories:
|
||||
https://github.com/freemkv/freemkv-keysources/security/advisories/new
|
||||
|
||||
Do not open a public issue for a security report. Include the affected
|
||||
version, steps to reproduce, and the impact you believe the issue has.
|
||||
|
||||
## Response time
|
||||
|
||||
You will get an initial response within 7 days.
|
||||
+160
-29
@@ -9,6 +9,10 @@ use base64::Engine;
|
||||
use libfreemkv::aacs::types::UnitKey;
|
||||
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
|
||||
use libfreemkv::{Error, KeySource};
|
||||
use ureq::config::Config;
|
||||
use ureq::http::Uri;
|
||||
use ureq::unversioned::resolver::{ResolvedSocketAddrs, Resolver};
|
||||
use ureq::unversioned::transport::{DefaultConnector, NextTimeout};
|
||||
|
||||
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
|
||||
// libfreemkv's `read_mkb_content` MAX_BYTES (64 MiB) so an MKB the library is
|
||||
@@ -217,16 +221,54 @@ pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
|
||||
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
|
||||
}
|
||||
|
||||
/// ureq's `ResolvedSocketAddrs` is a fixed 16-slot array and its `push` writes
|
||||
/// straight into that array, so handing it a 17th address is an out-of-bounds
|
||||
/// panic — in the rip thread, on a host that merely publishes a lot of A
|
||||
/// records. Keep the first 16; every one of them was validated by
|
||||
/// [`resolve_and_guard`], so a subset is still safe, just less redundant.
|
||||
const MAX_PINNED_ADDRS: usize = 16;
|
||||
|
||||
/// The pinned-address resolver behind [`hardened_agent`].
|
||||
///
|
||||
/// ureq 3 replaced v2's resolver closure with this trait. The agent MUST be
|
||||
/// built through `Agent::with_parts` to take one: `Agent::new_with_config`
|
||||
/// silently keeps the default resolver, which would send the request to live
|
||||
/// DNS and quietly reopen the rebinding window this module exists to close.
|
||||
/// That failure is invisible — it has no symptom short of an actual attack —
|
||||
/// so it is pinned by `hardened_agent_connects_to_the_pinned_address_not_dns`.
|
||||
#[derive(Debug)]
|
||||
struct PinnedResolver(Vec<SocketAddr>);
|
||||
|
||||
impl Resolver for PinnedResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
_uri: &Uri,
|
||||
_config: &Config,
|
||||
_timeout: NextTimeout,
|
||||
) -> Result<ResolvedSocketAddrs, ureq::Error> {
|
||||
let mut out = self.empty();
|
||||
for addr in self.0.iter().take(MAX_PINNED_ADDRS) {
|
||||
out.push(*addr);
|
||||
}
|
||||
if out.is_empty() {
|
||||
// The trait's contract: at least one address, or this error.
|
||||
return Err(ureq::Error::HostNotFound);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a ureq agent that follows zero redirects (so a public URL can't
|
||||
/// 30x-redirect to an internal host) and pins DNS resolution to `pinned`
|
||||
/// (the addresses already validated by [`resolve_and_guard`]).
|
||||
fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.redirects(0)
|
||||
.timeout_connect(Duration::from_secs(10))
|
||||
.timeout_read(Duration::from_secs(TIMEOUT_SECS))
|
||||
.resolver(move |_netloc: &str| Ok(pinned.clone()))
|
||||
.build()
|
||||
let config = Config::builder()
|
||||
.max_redirects(0)
|
||||
.timeout_connect(Some(Duration::from_secs(10)))
|
||||
.timeout_recv_response(Some(Duration::from_secs(TIMEOUT_SECS)))
|
||||
.build();
|
||||
// `with_parts`, never `new_with_config` — see [`PinnedResolver`].
|
||||
ureq::Agent::with_parts(config, DefaultConnector::new(), PinnedResolver(pinned))
|
||||
}
|
||||
|
||||
pub struct OnlineSource {
|
||||
@@ -384,7 +426,7 @@ impl OnlineSource {
|
||||
let agent = hardened_agent(pinned);
|
||||
let mut req = agent.post(&self.base_url);
|
||||
if let Some(value) = bearer_header(&self.secret) {
|
||||
req = req.set("Authorization", &value);
|
||||
req = req.header("Authorization", &value);
|
||||
}
|
||||
// Begin/end around the keyserver round-trip — a slow or unresponsive
|
||||
// service is the suspected DVD-scan hang. The agent is built with a
|
||||
@@ -433,11 +475,11 @@ fn classify_http_status(code: u16) -> Error {
|
||||
/// body carries base64 AACS key material and the reply carries keys — neither is
|
||||
/// ever logged, and nor is a serde parse error (it quotes the offending input).
|
||||
fn interpret_reply(
|
||||
sent: Result<ureq::Response, ureq::Error>,
|
||||
sent: Result<ureq::http::Response<ureq::Body>, ureq::Error>,
|
||||
ctx: &dyn ResolveCtx,
|
||||
elapsed_ms: u64,
|
||||
) -> Result<Vec<UnitKey>, Error> {
|
||||
let resp = match sent {
|
||||
let mut resp = match sent {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Record the HTTP status when the service actually answered:
|
||||
@@ -452,7 +494,7 @@ fn interpret_reply(
|
||||
// Each arm now RETURNS the classified error instead of an empty
|
||||
// vec, so the distinction survives past this function.
|
||||
return Err(match e {
|
||||
ureq::Error::Status(code, _) => {
|
||||
ureq::Error::StatusCode(code) => {
|
||||
let err = classify_http_status(code);
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
@@ -464,7 +506,14 @@ fn interpret_reply(
|
||||
);
|
||||
err
|
||||
}
|
||||
ureq::Error::Transport(_) => {
|
||||
// ureq 3 fans v2's single `Transport` variant out into `Io`,
|
||||
// `Timeout`, `Tls`, `Protocol`, `HostNotFound` and more, and the
|
||||
// enum is non_exhaustive. Every one of them means the same thing
|
||||
// here — nothing answered, so nothing is known about this disc —
|
||||
// and a catch-all is the only shape that stays correct as the
|
||||
// enum grows. The status arm above is the sole case where the
|
||||
// service actually replied.
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
@@ -486,7 +535,8 @@ fn interpret_reply(
|
||||
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
|
||||
let mut buf = Vec::new();
|
||||
if resp
|
||||
.into_reader()
|
||||
.body_mut()
|
||||
.as_reader()
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.is_err()
|
||||
@@ -752,6 +802,80 @@ mod tests {
|
||||
assert_eq!(addrs[0].port(), 8080);
|
||||
}
|
||||
|
||||
// ── the pin is actually consulted ──────────────────────────────────────
|
||||
//
|
||||
// `resolve_and_guard` VALIDATES addresses; `hardened_agent` PINS them, so a
|
||||
// hostname cannot re-resolve to an internal address between validation and
|
||||
// connection. The validation half is well covered above — but every one of
|
||||
// those tests still passes if the pinned resolver is never consulted,
|
||||
// because none of them makes a connection. Nothing else in this file proves
|
||||
// the agent honours the pin, and a mis-wired resolver fails OPEN: the
|
||||
// connection quietly falls back to live DNS and the rebinding window this
|
||||
// module exists to close is reopened, with no visible symptom.
|
||||
//
|
||||
// The discriminator: pin the agent to a loopback listener this test owns,
|
||||
// then ask it for a host that CANNOT resolve — `.test` is reserved by
|
||||
// RFC 6761 and never resolves in the public DNS. Only a consulted resolver
|
||||
// can turn that
|
||||
// name into a connection; a fallback to live DNS fails instead. No network
|
||||
// is touched, and `query` (whose guard blocks loopback by design) is not
|
||||
// involved — this drives `hardened_agent` directly.
|
||||
#[test]
|
||||
fn hardened_agent_connects_to_the_pinned_address_not_dns() {
|
||||
use std::io::Write as _;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener");
|
||||
let pinned = listener.local_addr().expect("stub listener address");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut sock, _peer) = listener.accept().expect("stub listener accept failed");
|
||||
// Signal that a connection ARRIVED before doing anything else: that
|
||||
// arrival, on this exact socket, is the fact under test.
|
||||
let _ = tx.send(());
|
||||
// Drain the request head, then answer with the smallest valid reply.
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
match sock.read(&mut byte) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => head.push(byte[0]),
|
||||
}
|
||||
}
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
|
||||
let _ = sock.flush();
|
||||
head
|
||||
});
|
||||
|
||||
let sent = hardened_agent(vec![pinned])
|
||||
.post("http://keyserver.test/keys")
|
||||
.send("{}");
|
||||
|
||||
// 1. The connection reached the pinned socket at all. Checked FIRST so
|
||||
// a mis-wired resolver reports the resolver, not a confusing
|
||||
// downstream symptom.
|
||||
rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"hardened_agent never connected to the pinned address — the custom \
|
||||
resolver is not being consulted, so a DNS rebind between the guard \
|
||||
and the POST can still redirect the key material",
|
||||
);
|
||||
// 2. The whole round-trip completed through it. Had the agent fallen
|
||||
// back to live DNS, an unresolvable host is an error, never a 200.
|
||||
let resp = sent.expect("the pinned round-trip must complete");
|
||||
assert_eq!(resp.status(), 200, "the stub server's reply must come back");
|
||||
// 3. The pin redirected the CONNECTION without rewriting the request:
|
||||
// the original host still travels in the Host header.
|
||||
let head = server.join().expect("stub server panicked");
|
||||
let head = String::from_utf8_lossy(&head);
|
||||
assert!(
|
||||
head.contains("keyserver.test"),
|
||||
"the pinned agent must still address the original host; got: {head}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── bearer_header ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -812,8 +936,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(status: u16, body: &str) -> ureq::Response {
|
||||
ureq::Response::new(status, "stub", body).expect("stub response")
|
||||
/// A stub reply. ureq 3 has no `Response::new`; a response is just an
|
||||
/// `http::Response` carrying a `ureq::Body`, which is constructible
|
||||
/// directly — so these stay honest unit tests with no server involved.
|
||||
fn reply(status: u16, body: &str) -> ureq::http::Response<ureq::Body> {
|
||||
ureq::http::Response::builder()
|
||||
.status(status)
|
||||
.body(ureq::Body::builder().data(body))
|
||||
.expect("stub response")
|
||||
}
|
||||
|
||||
/// THE regression. The key service returned HTTP 502 for ~seven hours and
|
||||
@@ -823,11 +953,7 @@ mod tests {
|
||||
#[test]
|
||||
fn http_5xx_is_a_source_failure_not_a_missing_key() {
|
||||
for status in [500u16, 502, 503, 504] {
|
||||
let out = interpret_reply(
|
||||
Err(ureq::Error::Status(status, reply(status, ""))),
|
||||
&BareCtx,
|
||||
7,
|
||||
);
|
||||
let out = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 7);
|
||||
assert_eq!(
|
||||
out.expect_err("a 5xx must not look like an answer").code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
@@ -842,7 +968,7 @@ mod tests {
|
||||
assert!(miss.is_empty(), "no key in the body → no keys out");
|
||||
|
||||
// And the two must not be the same outcome — the whole point.
|
||||
let down = interpret_reply(Err(ureq::Error::Status(502, reply(502, ""))), &BareCtx, 7);
|
||||
let down = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 7);
|
||||
assert!(
|
||||
down.is_err(),
|
||||
"502 and 200-with-no-entry must not collapse to the same result"
|
||||
@@ -880,19 +1006,24 @@ mod tests {
|
||||
|
||||
/// A transport failure — nothing answered at all — is the same verdict as a
|
||||
/// 5xx and equally not a missing key. Uses a refused connection to
|
||||
/// 127.0.0.1:1 to obtain a REAL `ureq::Error::Transport` (which has no public
|
||||
/// constructor); this touches no network and never reaches `query`, so the
|
||||
/// SSRF guard is not involved.
|
||||
/// 127.0.0.1:1 to obtain a REAL transport-class error; this touches no
|
||||
/// network and never reaches `query`, so the SSRF guard is not involved.
|
||||
#[test]
|
||||
fn transport_failure_is_a_source_failure() {
|
||||
let sent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_secs(2))
|
||||
.build()
|
||||
let config = Config::builder()
|
||||
.timeout_connect(Some(Duration::from_secs(2)))
|
||||
.build();
|
||||
let sent = ureq::Agent::new_with_config(config)
|
||||
.post("http://127.0.0.1:1/")
|
||||
.send_string("{}");
|
||||
.send("{}");
|
||||
// ureq 3 split v2's single `Transport` variant across `Io`,
|
||||
// `ConnectionFailed`, `Timeout` and friends, and which one a refused
|
||||
// connection produces is a platform detail. The claim that matters is
|
||||
// unchanged and is what this asserts: it failed, and NOT with a status
|
||||
// code — nothing on the other end ever answered.
|
||||
assert!(
|
||||
matches!(sent, Err(ureq::Error::Transport(_))),
|
||||
"a refused connection must surface as a ureq transport error"
|
||||
!matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))),
|
||||
"a refused connection must fail as transport, never as an HTTP status"
|
||||
);
|
||||
assert_eq!(
|
||||
interpret_reply(sent, &BareCtx, 3)
|
||||
|
||||
Reference in New Issue
Block a user