Skip to main content

src/aws/internal/providers/ecs.gleam

//// ECS container credentials provider.
////
//// One HTTP GET to a URL the container runtime advertises in environment
//// variables — typically `http://169.254.170.2<relative-uri>` inside an
//// ECS/EKS task. Response shape is the same as IMDS step 3 (a JSON
//// document with `AccessKeyId`, `SecretAccessKey`, `Token`, `Expiration`).
////
//// Auth token, when present, goes in the `Authorization` header — but only
//// when the destination is trusted (see `ecs_uri_allows_auth`). The token is
//// a bearer credential; attaching it to an arbitrary host advertised via
//// `AWS_CONTAINER_CREDENTIALS_FULL_URI` would exfiltrate it (issue #28). An
//// empty token value means "no auth header at all" (None) rather than "send
//// the empty string".

import aws/internal/datetime
import aws/internal/http_send.{type Send}
import gleam/bit_array
import gleam/dynamic/decode
import gleam/http
import gleam/http/request.{type Request}
import gleam/int
import gleam/json
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import gleam/uri

pub type Options {
  Options(url: String, auth_token: Option(String))
}

pub type EcsCredentials {
  EcsCredentials(
    access_key_id: String,
    secret_access_key: String,
    session_token: Option(String),
    expires_at: Option(Int),
  )
}

pub type Error {
  /// The metadata URL isn't reachable. The chain falls through.
  Unreachable(reason: String)
  /// URL responded but the body was malformed or signalled failure.
  Failed(reason: String)
}

pub fn fetch(send: Send, options: Options) -> Result(EcsCredentials, Error) {
  use req <- result.try(
    build_request(
      http.Get,
      options.url,
      auth_headers(options.url, options.auth_token),
    )
    |> result.map_error(fn(reason) { Failed(reason: reason) }),
  )
  use resp <- result.try(
    send(req)
    |> result.map_error(fn(e) {
      Unreachable(reason: "ECS metadata transport: " <> describe_http(e))
    }),
  )
  case resp.status {
    200 -> decode_credentials(resp.body)
    other ->
      Error(Failed(
        reason: "ECS metadata returned status " <> int.to_string(other),
      ))
  }
}

/// Build the auth headers for a request to `url`. The token is withheld —
/// the `Authorization` header is simply omitted — when `url` is not a trusted
/// destination, so an untrusted `FULL_URI` can never receive the bearer token
/// (issue #28). The request still goes out unauthenticated; the metadata
/// endpoint, if it really requires auth, then fails closed.
fn auth_headers(url: String, token: Option(String)) -> List(#(String, String)) {
  case token {
    Some(t) ->
      case ecs_uri_allows_auth(url) {
        True -> [#("authorization", t)]
        False -> []
      }
    None -> []
  }
}

/// Whether the metadata URL may receive the
/// `AWS_CONTAINER_AUTHORIZATION_TOKEN`. The token is a bearer credential, so
/// sending it to an arbitrary host over plain HTTP would leak it (SSRF /
/// credential exfiltration — issue #28). Mirroring aws-sdk-rust and
/// aws-sdk-go-v2, it is only attached when the destination is trusted:
///
/// - any `https` host (TLS protects the token in transit), or
/// - a loopback host: `127.0.0.0/8`, IPv6 `::1` / `[::1]`, or `localhost`, or
/// - the ECS (`169.254.170.2`) / EKS (`169.254.170.23`) link-local endpoints.
///
/// Any other host over plain HTTP returns `False` so the caller omits the
/// header entirely rather than leak the token. A URL that fails to parse is
/// treated as untrusted.
pub fn ecs_uri_allows_auth(url: String) -> Bool {
  case uri.parse(url) {
    Ok(parsed) ->
      case option.map(parsed.scheme, string.lowercase) {
        Some("https") -> True
        _ ->
          case parsed.host {
            Some(host) -> host_is_trusted(string.lowercase(host))
            None -> False
          }
      }
    Error(_) -> False
  }
}

/// Hosts allowed to receive the auth token over plain HTTP.
fn host_is_trusted(host: String) -> Bool {
  case host {
    "localhost" -> True
    "::1" | "[::1]" -> True
    // ECS / EKS container-credentials link-local endpoints.
    "169.254.170.2" -> True
    "169.254.170.23" -> True
    _ -> is_loopback_ipv4(host)
  }
}

/// True for any address in the `127.0.0.0/8` loopback block.
fn is_loopback_ipv4(host: String) -> Bool {
  case string.split(host, on: ".") {
    [first, b, c, d] ->
      first == "127" && is_octet(b) && is_octet(c) && is_octet(d)
    _ -> False
  }
}

/// True if `s` is a decimal byte (0–255), i.e. a valid dotted-quad octet.
fn is_octet(s: String) -> Bool {
  case int.parse(s) {
    Ok(n) -> n >= 0 && n <= 255
    Error(_) -> False
  }
}

fn build_request(
  method: http.Method,
  url: String,
  headers: List(#(String, String)),
) -> Result(Request(BitArray), String) {
  use base <- result.try(
    request.to(url)
    |> result.replace_error("invalid URL: " <> url),
  )
  let req =
    base
    |> request.set_method(method)
    |> request.set_body(bit_array.from_string(""))
  Ok(apply_headers(req, headers))
}

fn apply_headers(
  req: Request(BitArray),
  headers: List(#(String, String)),
) -> Request(BitArray) {
  case headers {
    [] -> req
    [#(k, v), ..rest] -> apply_headers(request.set_header(req, k, v), rest)
  }
}

fn describe_http(error: http_send.HttpError) -> String {
  case error {
    http_send.ConnectFailed(reason: reason) -> "connect failed: " <> reason
    http_send.Timeout -> "timeout"
    http_send.InvalidBody(reason: reason) -> "invalid body: " <> reason
    http_send.Other(reason: reason) -> reason
  }
}

// ---- response decoding ----

type RawCredentials {
  RawCredentials(
    access_key_id: String,
    secret_access_key: String,
    token: Option(String),
    expiration: Option(String),
  )
}

fn raw_decoder() -> decode.Decoder(RawCredentials) {
  use access_key_id <- decode.field("AccessKeyId", decode.string)
  use secret_access_key <- decode.field("SecretAccessKey", decode.string)
  use token <- decode.then(decode.optionally_at(
    ["Token"],
    None,
    decode.map(decode.string, Some),
  ))
  use expiration <- decode.then(decode.optionally_at(
    ["Expiration"],
    None,
    decode.map(decode.string, Some),
  ))
  decode.success(RawCredentials(
    access_key_id: access_key_id,
    secret_access_key: secret_access_key,
    token: token,
    expiration: expiration,
  ))
}

fn decode_credentials(body: BitArray) -> Result(EcsCredentials, Error) {
  use text <- result.try(
    bit_array.to_string(body)
    |> result.replace_error(Failed(reason: "non-utf8 credentials body")),
  )
  use raw <- result.try(
    json.parse(text, raw_decoder())
    |> result.map_error(fn(_) {
      Failed(reason: "ECS response is not the expected JSON shape")
    }),
  )
  // Expiration is technically optional in the wire format, but in practice
  // ECS always returns it. We tolerate it being absent (treat creds as
  // non-expiring) so we don't blow up on a one-off edge case.
  let expires_at = case raw.expiration {
    Some(ts) ->
      case datetime.parse_iso8601(ts) {
        Ok(t) -> Some(t)
        Error(_) -> None
      }
    None -> None
  }
  Ok(EcsCredentials(
    access_key_id: raw.access_key_id,
    secret_access_key: raw.secret_access_key,
    session_token: raw.token,
    expires_at: expires_at,
  ))
}