#![cfg(feature = "http")]
use link_r::auth::{AuthProvider, Credential, StaticTokenAuth};
use link_r::fetch::{FetchOptions, Fetcher, HttpFetcher};
use link_r::resource::Resource;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
use url::Url;
fn serve_once(listener: TcpListener, response: String, tx: mpsc::Sender<bool>) {
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]).to_lowercase();
let had_auth = req.contains("authorization:");
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
let _ = tx.send(had_auth);
} else {
let _ = tx.send(false);
}
});
}
#[tokio::test]
async fn authorization_is_stripped_on_cross_host_redirect() {
let a = TcpListener::bind("127.0.0.1:0").unwrap();
let b = TcpListener::bind("127.0.0.1:0").unwrap();
let a_port = a.local_addr().unwrap().port();
let b_port = b.local_addr().unwrap().port();
let (tx_a, rx_a) = mpsc::channel();
let (tx_b, rx_b) = mpsc::channel();
serve_once(
a,
format!("HTTP/1.1 301 Moved Permanently\r\nLocation: http://127.0.0.1:{b_port}/t\r\nContent-Length: 0\r\n\r\n"),
tx_a,
);
serve_once(
b,
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok".to_string(),
tx_b,
);
let fetcher = HttpFetcher::new(StaticTokenAuth::bearer("SUPER-SECRET-PAT")).unwrap();
let resource = Resource::new(Url::parse(&format!("http://localhost:{a_port}/")).unwrap());
let fetched = fetcher.fetch(&resource, FetchOptions::default()).await;
assert!(fetched.is_ok(), "redirect should be followed: {fetched:?}");
let a_had_auth = rx_a
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
let b_had_auth = rx_b
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
assert!(a_had_auth, "the original host must receive the token");
assert!(
!b_had_auth,
"the token must NOT survive a cross-host redirect"
);
}
#[test]
fn scoped_token_withheld_from_foreign_host() {
let auth = StaticTokenAuth::bearer_scoped("SECRET", "github.com");
let foreign = Resource::new(Url::parse("https://raw.githubusercontent.com/x").unwrap());
assert!(matches!(auth.credential(&foreign), Credential::Anonymous));
let home = Resource::new(Url::parse("https://github.com/o/r").unwrap());
assert!(matches!(auth.credential(&home), Credential::Bearer(_)));
}
#[test]
fn token_is_never_in_debug_output() {
let secret = "ghp_TopSecretValue123";
let auth = StaticTokenAuth::bearer_scoped(secret, "github.com");
assert!(!format!("{auth:?}").contains(secret));
let fetcher = HttpFetcher::new(auth).unwrap();
assert!(!format!("{fetcher:?}").contains(secret));
}
#[cfg(feature = "crawl")]
#[tokio::test]
async fn persisted_index_never_contains_url_userinfo() {
use link_r::facade::LinkIndex;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("kb.lnkr");
let mut idx = LinkIndex::in_memory().unwrap();
use link_r::index::{Document, IndexBuilder};
use link_r::metric::Metric;
use link_r::resource::ResourceKind;
let mut builder = IndexBuilder::new(1, Metric::Cosine, 0);
builder
.upsert(Document {
url: Url::parse("https://alice:hunter2@secret.dev/docs/a").unwrap(),
kind: ResourceKind::Html,
content_hash: 1,
title: None,
snippet: "s".into(),
lang: None,
tags: Default::default(),
terms: vec!["term".into()],
vector: vec![1.0],
edges: Vec::new(),
fetched_at_ms: 0,
etag: None,
pinned: false,
})
.unwrap();
builder.save(&path).unwrap();
let bytes = std::fs::read(&path).unwrap();
let haystack = String::from_utf8_lossy(&bytes);
assert!(
!haystack.contains("hunter2"),
"index must not persist URL password"
);
assert!(
!haystack.contains("alice:hunter2"),
"index must not persist URL userinfo"
);
let _ = &mut idx;
}