Skip to content

Commit f014d2c

Browse files
committed
feat: proxy provider-display-config icons through the API
Add GET /admin/api/v1/provider-display-configs/{provider_key}/icon, a server-side proxy that fetches the operator-set icon URL and streams the bytes back to the browser under the same origin as the dashboard. The motivation is the dashboard's Content-Security-Policy. `icon` URLs in provider_display_configs are operator-pasted and span many external hosts (npmmirror, simpleicons, Wikipedia, GitHub avatars, Webflow CDNs, ...) that change as new providers are added. Enumerating each host in `img-src` is a treadmill; the alternative — widening `img-src` to allow any HTTPS origin — loosens CSP for every image load. Routing icons through this endpoint means the browser only ever loads them from `'self'`, so `img-src` can stay tight regardless of where operators source their logos. Safety on the upstream fetch: - only `https://` URLs are proxied; relative paths / registry-key shortcuts return 404 and stay handled client-side - 5 s total timeout - 2 MiB body cap, enforced while streaming - 3-redirect limit - upstream `Content-Type` must start with `image/`; the bytes are returned with that exact type and a 1 h `Cache-Control` Follow-ups (separate PRs): - app-doubleword-private: SPA renders icons via this endpoint - internal: once both ship, tighten the CSP `img-src` back to 'self' data: blob:'
1 parent e683577 commit f014d2c

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

dwctl/src/api/handlers/provider_display_configs.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ use crate::db::models::provider_display_configs::{ProviderDisplayConfigCreateDBR
1010
use crate::errors::{Error, Result};
1111
use axum::{
1212
Json,
13+
body::Body,
1314
extract::{Path, State},
1415
http::StatusCode,
16+
response::Response,
1517
};
1618
use std::collections::{BTreeMap, HashMap};
19+
use std::time::Duration;
20+
use url::Url;
1721

1822
fn normalize_provider_key(value: &str) -> String {
1923
value.trim().to_lowercase()
@@ -283,6 +287,153 @@ pub async fn delete_provider_display_config<P: PoolProvider>(
283287
Ok(StatusCode::NO_CONTENT)
284288
}
285289

290+
// ---------- Icon proxy ----------
291+
292+
/// Cap on bytes returned from the upstream icon URL. Icons are SVG/PNG logos;
293+
/// anything larger is either a misconfiguration or an upstream that returned
294+
/// something unexpected (HTML error page, etc.) — better to fail closed.
295+
const MAX_ICON_SIZE_BYTES: u64 = 2 * 1024 * 1024;
296+
297+
/// Total time (connect + read) we'll spend talking to the upstream icon host
298+
/// before giving up. Provider logos are tiny static files; if the upstream
299+
/// can't deliver in this window, it's not going to.
300+
const ICON_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
301+
302+
/// `Cache-Control` returned to the browser. Provider icons effectively never
303+
/// change at a given URL — `staleTime` on the SPA's TanStack Query already
304+
/// keeps the list response cached for 30 minutes, so an hour here is conservative.
305+
const ICON_CACHE_CONTROL: &str = "public, max-age=3600";
306+
307+
/// Server-side proxy for operator-set provider icon URLs.
308+
///
309+
/// `provider_display_configs.icon` is set by admins through the admin surface
310+
/// and currently points at arbitrary external hosts (npmmirror, simpleicons,
311+
/// Wikipedia, GitHub avatars, Webflow CDNs, ...) that change each time a new
312+
/// provider is added. Routing the dashboard's icon loads through this endpoint
313+
/// means the browser only ever fetches icons from the same origin, so the SPA
314+
/// can run with a tight `img-src 'self' data: blob:` CSP rather than allowing
315+
/// every external image host that an admin might paste in.
316+
///
317+
/// Returns:
318+
/// - **200** with the upstream bytes and `Content-Type` (must be `image/*`)
319+
/// - **404** if the provider has no config, no icon set, or the configured
320+
/// value isn't an absolute `https://` URL (relative paths and registry-key
321+
/// shortcuts are handled client-side and bypass this endpoint)
322+
/// - **500** if the upstream fetch fails, times out, exceeds 2 MiB, returns
323+
/// non-2xx, or serves a non-image Content-Type
324+
#[utoipa::path(
325+
get,
326+
path = "/provider-display-configs/{provider_key}/icon",
327+
tag = "provider-display-configs",
328+
summary = "Proxy the provider's configured icon",
329+
description = "Fetches the operator-set icon URL server-side and streams it back. Lets the SPA keep a tight CSP regardless of where the icon URL points.",
330+
params(("provider_key" = String, Path)),
331+
responses(
332+
(status = 200, description = "Icon bytes"),
333+
(status = 404, description = "Provider or icon not found / not proxyable"),
334+
(status = 500, description = "Upstream icon fetch failed"),
335+
),
336+
security(
337+
("BearerAuth" = []),
338+
("CookieAuth" = []),
339+
("X-Doubleword-User" = [])
340+
)
341+
)]
342+
#[tracing::instrument(skip_all, fields(provider_key = %provider_key))]
343+
pub async fn get_provider_display_config_icon<P: PoolProvider>(
344+
State(state): State<AppState<P>>,
345+
Path(provider_key): Path<String>,
346+
_: RequiresPermission<resource::Models, operation::ReadOwn>,
347+
) -> Result<Response> {
348+
let provider_key = normalize_provider_key(&provider_key);
349+
350+
let icon_value = {
351+
let mut conn = state.db.read().acquire().await.map_err(|e| Error::Database(e.into()))?;
352+
let mut repo = ProviderDisplayConfigs::new(&mut conn);
353+
repo.get_by_key(&provider_key)
354+
.await?
355+
.ok_or_else(|| Error::NotFound {
356+
resource: "provider display config".to_string(),
357+
id: provider_key.clone(),
358+
})?
359+
.icon
360+
.unwrap_or_default()
361+
};
362+
363+
// Only proxy absolute https:// URLs. Relative paths (`/brand/...`) and
364+
// registry-key shortcuts (`anthropic`, `google`) are resolved by the SPA
365+
// and don't need (or want) to round-trip through this endpoint.
366+
let icon_url = match Url::parse(&icon_value) {
367+
Ok(u) if u.scheme() == "https" => u,
368+
_ => {
369+
return Err(Error::NotFound {
370+
resource: "proxyable icon for provider".to_string(),
371+
id: provider_key,
372+
});
373+
}
374+
};
375+
376+
let client = reqwest::Client::builder()
377+
.timeout(ICON_FETCH_TIMEOUT)
378+
.redirect(reqwest::redirect::Policy::limited(3))
379+
.build()
380+
.map_err(|e| Error::Other(anyhow::anyhow!("build http client: {e}")))?;
381+
382+
let resp = client.get(icon_url).send().await.map_err(|e| {
383+
tracing::warn!(error = %e, "provider icon upstream fetch failed");
384+
Error::Other(anyhow::anyhow!("fetch provider icon: {e}"))
385+
})?;
386+
387+
let upstream_status = resp.status();
388+
if !upstream_status.is_success() {
389+
return Err(Error::Other(anyhow::anyhow!(
390+
"upstream icon host returned status {upstream_status}",
391+
)));
392+
}
393+
394+
let content_type = resp
395+
.headers()
396+
.get(reqwest::header::CONTENT_TYPE)
397+
.and_then(|v| v.to_str().ok())
398+
.map(|s| s.to_string())
399+
.unwrap_or_else(|| "application/octet-stream".to_string());
400+
401+
// Refuse to proxy anything that isn't an image — guards against an
402+
// upstream returning an HTML error page that the browser would then try
403+
// to render in an <img> tag.
404+
if !content_type.to_ascii_lowercase().starts_with("image/") {
405+
return Err(Error::Other(anyhow::anyhow!(
406+
"upstream icon content-type {content_type:?} is not image/*",
407+
)));
408+
}
409+
410+
let body_bytes = read_capped_body(resp, MAX_ICON_SIZE_BYTES).await.map_err(|e| {
411+
tracing::warn!(error = %e, "provider icon body read failed");
412+
Error::Other(anyhow::anyhow!("read provider icon body: {e}"))
413+
})?;
414+
415+
Response::builder()
416+
.status(StatusCode::OK)
417+
.header(axum::http::header::CONTENT_TYPE, content_type)
418+
.header(axum::http::header::CACHE_CONTROL, ICON_CACHE_CONTROL)
419+
.body(Body::from(body_bytes))
420+
.map_err(|e| Error::Other(anyhow::anyhow!("build icon response: {e}")))
421+
}
422+
423+
/// Drain a `reqwest::Response` body, returning early with an error if the
424+
/// accumulated bytes would exceed `max`. Avoids buffering an unbounded body
425+
/// from an untrusted upstream.
426+
async fn read_capped_body(mut resp: reqwest::Response, max: u64) -> anyhow::Result<bytes::Bytes> {
427+
let mut buf = bytes::BytesMut::new();
428+
while let Some(chunk) = resp.chunk().await? {
429+
if (buf.len() as u64) + (chunk.len() as u64) > max {
430+
anyhow::bail!("icon body exceeds {max} bytes");
431+
}
432+
buf.extend_from_slice(&chunk);
433+
}
434+
Ok(buf.freeze())
435+
}
436+
286437
#[cfg(test)]
287438
mod tests {
288439
use crate::api::models::provider_display_configs::ProviderDisplayConfigResponse;

dwctl/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,10 @@ pub async fn build_router(
11891189
"/provider-display-configs/{provider_key}",
11901190
delete(api::handlers::provider_display_configs::delete_provider_display_config),
11911191
)
1192+
.route(
1193+
"/provider-display-configs/{provider_key}/icon",
1194+
get(api::handlers::provider_display_configs::get_provider_display_config_icon),
1195+
)
11921196
// Composite model component management (for models where is_composite=true)
11931197
.route("/models/{id}/components", get(api::handlers::deployments::get_model_components))
11941198
.route(

dwctl/src/openapi/admin.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ impl Modify for AdminSecurityAddon {
8585
api::handlers::provider_display_configs::create_provider_display_config,
8686
api::handlers::provider_display_configs::update_provider_display_config,
8787
api::handlers::provider_display_configs::delete_provider_display_config,
88+
api::handlers::provider_display_configs::get_provider_display_config_icon,
8889
api::handlers::groups::list_groups,
8990
api::handlers::groups::create_group,
9091
api::handlers::groups::get_group,

0 commit comments

Comments
 (0)