#!/usr/bin/env python3 """Expose EC2 instance-profile credentials through the STS web-identity shape.""" import json import os import sys import urllib.parse import urllib.request import uuid from http.server import BaseHTTPRequestHandler, HTTPServer from xml.sax.saxutils import escape IMDS = "http://169.254.169.254/latest" EXPECTED_ROLE = os.environ.get("CP9_EXPECTED_ROLE_ARN", "") EXPECTED_TOKEN = "cp9-remote-ec2-k3d" def metadata(path): token_request = urllib.request.Request( f"{IMDS}/api/token", method="PUT", headers={"X-aws-ec2-metadata-token-ttl-seconds": "60"}, ) token = urllib.request.urlopen(token_request, timeout=5).read().decode() request = urllib.request.Request( f"{IMDS}/meta-data/{path}", headers={"X-aws-ec2-metadata-token": token}, ) return urllib.request.urlopen(request, timeout=5).read().decode() def assume_role_response(role_arn, session_name, web_identity_token): if not EXPECTED_ROLE or role_arn != EXPECTED_ROLE: raise ValueError("unexpected role") if web_identity_token != EXPECTED_TOKEN: raise ValueError("unexpected web identity token") profile = metadata("iam/security-credentials/").strip() credentials = json.loads(metadata(f"iam/security-credentials/{profile}")) required = ("AccessKeyId", "SecretAccessKey", "Token", "Expiration") if credentials.get("Code") != "Success" or not all(credentials.get(key) for key in required): raise RuntimeError("EC2 instance-profile credentials are unavailable") account = role_arn.split(":")[4] role_name = role_arn.rsplit("/", 1)[-1] assumed_role_arn = f"arn:aws:sts::{account}:assumed-role/{role_name}/{session_name}" return f""" sts.amazonaws.com {escape(assumed_role_arn)} cp9:{escape(session_name)} {escape(credentials["AccessKeyId"])} {escape(credentials["SecretAccessKey"])} {escape(credentials["Token"])} {escape(credentials["Expiration"])} cp9-remote-ec2-k3d {EXPECTED_TOKEN} {uuid.uuid4()} """ class Handler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200 if self.path == "/healthz" else 404) self.end_headers() def do_POST(self): try: length = int(self.headers.get("Content-Length", "0")) if length <= 0 or length > 16384: raise ValueError("invalid request size") form = urllib.parse.parse_qs(self.rfile.read(length).decode()) if form.get("Action") != ["AssumeRoleWithWebIdentity"]: raise ValueError("unexpected action") payload = assume_role_response( form.get("RoleArn", [""])[0], form.get("RoleSessionName", [""])[0], form.get("WebIdentityToken", [""])[0], ).encode() self.send_response(200) self.send_header("Content-Type", "text/xml") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) except Exception as exc: payload = str(exc).encode() self.send_response(500) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, _format, *_args): pass def self_test(): global EXPECTED_ROLE, metadata EXPECTED_ROLE = "arn:aws:iam::123456789012:role/cp9-runtime" values = { "iam/security-credentials/": "cp9-runtime", "iam/security-credentials/cp9-runtime": json.dumps( { "Code": "Success", "AccessKeyId": "AKIA_TEST", "SecretAccessKey": "secret", "Token": "token", "Expiration": "2030-01-01T00:00:00Z", } ), } metadata = values.__getitem__ response = assume_role_response(EXPECTED_ROLE, "proof", EXPECTED_TOKEN) assert "AKIA_TEST" in response assert "assumed-role/cp9-runtime/proof" in response if __name__ == "__main__": if sys.argv[1:] == ["--self-test"]: self_test() else: HTTPServer(("127.0.0.1", 8085), Handler).serve_forever()