harden proxy auth and event ordering
This commit is contained in:
@@ -5,7 +5,7 @@ async function pollForRequests() {
|
||||
isPolling = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pending');
|
||||
const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Polled for requests, got:', data);
|
||||
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
|
||||
|
||||
console.log('Sending response:', responsePayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
|
||||
|
||||
console.log('Sending error response:', errorPayload);
|
||||
|
||||
await fetch('/api/response', {
|
||||
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -265,6 +265,10 @@ impl BrowserSignerProxy {
|
||||
let state: Arc<ProxyState> = self.inner.state.clone();
|
||||
let custom_html: &'static str = self.inner.options.custom_html;
|
||||
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
|
||||
// Regenerated on every startup, never part of the opened URL
|
||||
let session_token: String = Uuid::new_v4().simple().to_string();
|
||||
// Only the exact host:port the server binds may appear in the Host header
|
||||
let expected_host: String = addr.to_string();
|
||||
|
||||
smol::spawn(async move {
|
||||
tracing::info!("Starting proxy server on {addr}");
|
||||
@@ -291,13 +295,22 @@ impl BrowserSignerProxy {
|
||||
AcceptEvent::Connection(Ok((stream, _))) => {
|
||||
let state: Arc<ProxyState> = state.clone();
|
||||
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
|
||||
let expected_host: String = expected_host.clone();
|
||||
let session_token: String = session_token.clone();
|
||||
|
||||
smol::spawn(async move {
|
||||
let shutdown_listener = shutdown.listen();
|
||||
|
||||
smol::future::or(
|
||||
async {
|
||||
handle_connection(stream, state, custom_html).await;
|
||||
handle_connection(
|
||||
stream,
|
||||
state,
|
||||
custom_html,
|
||||
expected_host,
|
||||
session_token,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
async {
|
||||
shutdown_listener.await;
|
||||
@@ -336,6 +349,18 @@ impl BrowserSignerProxy {
|
||||
outgoing_requests.push(request);
|
||||
}
|
||||
|
||||
/// Drop a request from both queues so timed-out request data is not retained.
|
||||
#[inline]
|
||||
async fn cancel_request(&self, id: Uuid) {
|
||||
self.inner.state.pending_responses.lock().await.remove(&id);
|
||||
self.inner
|
||||
.state
|
||||
.outgoing_requests
|
||||
.lock()
|
||||
.await
|
||||
.retain(|request| request.id != id);
|
||||
}
|
||||
|
||||
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
@@ -345,6 +370,7 @@ impl BrowserSignerProxy {
|
||||
|
||||
// Construct the request
|
||||
let request: RequestData = RequestData::new(method, params);
|
||||
let request_id = request.id;
|
||||
|
||||
// Create a bounded channel of size 1 as a oneshot replacement
|
||||
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
|
||||
@@ -361,7 +387,10 @@ impl BrowserSignerProxy {
|
||||
match response {
|
||||
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
|
||||
Ok(Err(error)) => Err(Error::generic(error)),
|
||||
Err(TimeoutError) => Err(Error::timeout()),
|
||||
Err(TimeoutError) => {
|
||||
self.cancel_request(request_id).await;
|
||||
Err(Error::timeout())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,10 +466,14 @@ impl AsyncNip44 for BrowserSignerProxy {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minimal HTTP server ──────────────────────────────────────────────────
|
||||
|
||||
/// Handle a single HTTP connection.
|
||||
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
|
||||
async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
state: Arc<ProxyState>,
|
||||
custom_html: &'static str,
|
||||
expected_host: String,
|
||||
session_token: String,
|
||||
) {
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
// Read the request line
|
||||
@@ -462,24 +495,49 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
||||
// Read headers until empty line
|
||||
let mut headers = Vec::new();
|
||||
let mut content_length: usize = 0;
|
||||
let mut host: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let line = line.trim_end().to_string();
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(value) = line.strip_prefix("content-length:") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("Content-Length:") {
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("host:") {
|
||||
host = Some(value.trim().to_string());
|
||||
} else if let Some(value) = line.strip_prefix("Host:") {
|
||||
host = Some(value.trim().to_string());
|
||||
}
|
||||
|
||||
headers.push(line);
|
||||
}
|
||||
|
||||
match (method.as_str(), path.as_str()) {
|
||||
// DNS rebinding defense: only the exact bound host:port is accepted
|
||||
if host.as_deref() != Some(expected_host.as_str()) {
|
||||
send_response(&mut reader, 403, "Forbidden", "", "").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let (route, query) = match path.split_once('?') {
|
||||
Some((route, query)) => (route, Some(query)),
|
||||
None => (path.as_str(), None),
|
||||
};
|
||||
|
||||
if route.starts_with("/api/") && query_param(query, "token") != Some(session_token.as_str()) {
|
||||
send_response(&mut reader, 403, "Forbidden", "", "").await;
|
||||
return;
|
||||
}
|
||||
|
||||
match (method.as_str(), route) {
|
||||
// Serve the HTML proxy page
|
||||
("GET", "/") => {
|
||||
let html = if custom_html.is_empty() {
|
||||
@@ -487,7 +545,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
||||
} else {
|
||||
custom_html
|
||||
};
|
||||
send_response(&mut reader, 200, "OK", "text/html", html).await;
|
||||
let html = inject_session_token(html, &session_token);
|
||||
send_response(&mut reader, 200, "OK", "text/html", &html).await;
|
||||
}
|
||||
// Serve the JS proxy script
|
||||
("GET", "/proxy.js") => {
|
||||
@@ -516,7 +575,7 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
||||
// Clear the outgoing requests after sending them
|
||||
outgoing.clear();
|
||||
|
||||
send_response_cors_json(&mut reader, 200, "OK", &json).await;
|
||||
send_response(&mut reader, 200, "OK", "application/json", &json).await;
|
||||
}
|
||||
// Receive response from browser extension
|
||||
("POST", "/api/response") => {
|
||||
@@ -541,27 +600,16 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
|
||||
let mut pending = state.pending_responses.lock().await;
|
||||
|
||||
match pending.remove(&id) {
|
||||
Some(sender) => {
|
||||
// Use try_send since we already hold the lock
|
||||
let _ = sender.try_send(message.into_result());
|
||||
tracing::info!("Forwarded response for request {id}");
|
||||
}
|
||||
Some(sender) => match sender.try_send(message.into_result()) {
|
||||
Ok(()) => tracing::info!("Forwarded response for request {id}"),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to forward response for request {id}: {e}")
|
||||
}
|
||||
},
|
||||
None => tracing::warn!("No pending request found for {id}"),
|
||||
}
|
||||
|
||||
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||
}
|
||||
// CORS preflight
|
||||
("OPTIONS", _) => {
|
||||
let response = "HTTP/1.1 200 OK\r\n\
|
||||
Access-Control-Allow-Origin: *\r\n\
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
|
||||
Access-Control-Allow-Headers: Content-Type\r\n\
|
||||
Content-Length: 0\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
let _ = reader.get_mut().write_all(response.as_bytes()).await;
|
||||
let _ = reader.get_mut().flush().await;
|
||||
send_response(&mut reader, 200, "OK", "text/plain", "OK").await;
|
||||
}
|
||||
// 404 - not found
|
||||
_ => {
|
||||
@@ -585,68 +633,44 @@ async fn send_response(
|
||||
}
|
||||
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
write_response(stream, &response).await;
|
||||
}
|
||||
|
||||
/// Write a response with CORS headers and JSON content type.
|
||||
async fn send_response_cors_json(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
response.push_str("Content-Type: application/json\r\n");
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
/// Write a response with CORS headers.
|
||||
async fn send_response_cors(
|
||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
||||
status: u16,
|
||||
status_text: &str,
|
||||
content_type: &str,
|
||||
body: &str,
|
||||
) {
|
||||
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
|
||||
|
||||
if !content_type.is_empty() {
|
||||
response.push_str(&format!("Content-Type: {content_type}\r\n"));
|
||||
async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
|
||||
if let Err(e) = stream.write_all(response.as_bytes()).await {
|
||||
tracing::debug!("Failed to write response to peer: {e}");
|
||||
}
|
||||
if let Err(e) = stream.flush().await {
|
||||
tracing::debug!("Failed to flush response to peer: {e}");
|
||||
}
|
||||
|
||||
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
|
||||
response.push_str("Access-Control-Allow-Origin: *\r\n");
|
||||
response.push_str("Connection: close\r\n");
|
||||
response.push_str("\r\n");
|
||||
response.push_str(body);
|
||||
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.flush().await;
|
||||
}
|
||||
|
||||
// ── Timeout helper ───────────────────────────────────────────────────────
|
||||
/// Get a parameter value from an optional query string.
|
||||
fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
|
||||
let query = query?;
|
||||
query.split('&').find_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
(key == name).then_some(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn inject_session_token(html: &str, token: &str) -> String {
|
||||
let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
|
||||
match html.rfind("</body>") {
|
||||
Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
|
||||
None => format!("{html}{script}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An error indicating that an operation timed out.
|
||||
#[derive(Debug)]
|
||||
struct TimeoutError;
|
||||
|
||||
/// Races a channel receive against a duration.
|
||||
///
|
||||
/// Returns the channel value on success, or [`TimeoutError`] if the duration
|
||||
/// elapses first or the channel is closed.
|
||||
async fn race_timeout<T>(
|
||||
duration: Duration,
|
||||
recv: impl Future<Output = Result<T, channel::RecvError>>,
|
||||
@@ -677,10 +701,6 @@ async fn race_timeout<T>(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utility ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
|
||||
/// time is before the epoch, returns 0.
|
||||
#[inline]
|
||||
fn current_time() -> u64 {
|
||||
SystemTime::now()
|
||||
|
||||
Reference in New Issue
Block a user