LiteLLM Supply Chain Attack: How Poisoned Version 1.107.0 Stole AI Keys, and How to Check Your Systems
On 21 October 2026, a poisoned release of LiteLLM, the open-source gateway many teams put between their apps and OpenAI, Anthropic or Google, sat on PyPI and Docker Hub for about two hours, long enough for roughly 49,000 downloads. It stole whatever keys the machine could reach, and it ran even if your code never imported LiteLLM.
This LiteLLM supply chain attack is the project's second of 2026. Below: a quick self-check, the timeline, what to rotate and the build habits that stop the next one. Most of those habits live in your pipeline, which our CI/CD pipelines work sets up.
Am I affected by the LiteLLM attack? The 30-second answer
You're at risk if any environment installed litellm 1.107.0, or ran a LiteLLM Docker image on a floating tag, on 21 October 2026 during the roughly two-hour window. The malware ran whenever Python started, so if that machine had internet access, assume the keys it could reach were stolen. Pinning 1.106.2 or earlier kept you out of this incident.
You're probably affected if:
- An environment installed litellm==1.107.0 from PyPI on 21 October 2026, roughly between 11:39 and 13:23 UTC.
- A container ran ghcr.io/berriai/litellm or the Docker Hub image with the tag v1.107.0, latest, main-latest or main-stable, pulled between 11:46 and 14:20 UTC that day.
- A dependency pulled in LiteLLM unpinned and got 1.107.0.
LiteLLM says its private enterprise registry was not touched.
The LiteLLM supply chain attack, minute by minute
This timeline follows LiteLLM's canonical record of 23 October, which corrected early assumptions it called "inaccurate or incomplete" (times UTC).
| When | What happened |
| --- | --- |
| 27 May 2026 | A master key for LiteLLM's experimental Agent Platform is committed to a public repository in an opencode.json file. The commit is overwritten; the key is never revoked. |
| 10 October, 21:27 | Using that key, the attacker gets the Agent Platform API to return a GitHub organisation owner's GitHub CLI token. |
| 10 October, 22:11 | With the owner's token, the attacker runs workflows that post CI secrets, including GitHub tokens, to webhook.site, then deletes the runs. A second round follows on 12 October. |
| 21 October, 10:08 | The owner's token overrides branch protection on the private release workflow. |
| 21 October, 10:46 | A build runs from a lookalike repository, Berri-AI/litellm (the real one is BerriAI/litellm). |
| 21 October, 11:11 | A maintainer's stolen token approves the production release gate. |
| 21 October, 11:39:51 | Seven malicious litellm==1.107.0 wheels go up on PyPI over 22 seconds. |
| 21 October, 11:46:56 | Poisoned images are published to Docker Hub and GHCR. |
| 21 October, 11:53 | Another stolen credential deletes the workflow records. |
| 21 October, 12:06 | A community member opens GitHub issue #47826 reporting the compromise. |
| 21 October, by 13:23 | PyPI quarantines 1.107.0. |
| 21 October, 14:20 | The last Docker Hub tags are removed or repointed. |
Sources differ on the clean-up times. Datadog and Wiz put PyPI's quarantine at about 13:20 and image removal at about 13:53; LiteLLM says GHCR was clean by 13:51 but Docker Hub tags pointed at the bad image until 14:20. If you pull from Docker Hub, use the later time.
The stolen data was used fast. Wiz saw AWS instance-role credentials taken from victims being tested from two IP addresses between 16:20 and 17:01 UTC.
Early coverage blamed stolen GitHub CLI tokens from three engineers: broadly right, but the 23 October record adds the first link (an unrevoked key leaked nearly five months earlier) and rules out an early theory that the tokens came from an internal deployment's logs.
Why Python .pth startup hooks make this nastier than a normal malicious package
Most malicious packages need importing; this one didn't. The wheel dropped three files into site-packages, outside the litellm folder:
- cook.pth, containing one line, import cook.
- cook.py, the credential stealer.
- sitecustomize.py, a backdoor loader.
Python reads every .pth file in site-packages each time it starts, executes any line beginning with import, and imports any module called sitecustomize. Python's documentation says these lines "are always run at Python startup ... regardless of whether a particular module is actually going to be used." They're now marked deprecated, but they still run.
In practice:
- Any Python process ran the stealer: tests, a Jupyter kernel, a cron job. Microsoft saw a Windows case where installing an unrelated dependency triggered it.
- pip uninstall litellm doesn't remove it. The files sit outside the package folder.
- Checking with Python can re-trigger it. Running python or pip in a suspect environment starts the hooks again.
- The source code looked clean. The official v1.107.0 tag never contained the payload. Endor Labs found one added line in the build settings, data = "cook" in pyproject.toml, copied a cook/ folder that isn't in the public source into the wheel.
The line worth remembering: with a .pth hook, installing the package is the same as running it, every time Python starts.
What the malware stole
Microsoft, Datadog and Wiz agree on what cook.py collected:
- Cloud: AWS, Google Cloud and Azure credentials, including instance-role credentials from metadata services; Kubernetes, Docker, Vault and Terraform secrets.
- Developer secrets: SSH, GPG and TLS keys; Git, npm and PyPI tokens; .env files; shell history; process environment variables.
- AI tools: provider API keys and sessions for AI coding assistants, IDEs and MCP configs.
- Browsers: saved passwords and cookies.
- LiteLLM's own database: up to 500 rows per query from LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable, where the upstream provider keys live.
The haul was encrypted and posted to www.litellm.dev/healthcheck, an attacker-controlled lookalike domain.
Separately, sitecustomize.py started a background process on Unix-like systems that checked a GitHub file hourly for a server address, then would download and run code from it. That file only ever held test values, so no second stage was seen.
Why AI gateways are prime targets
An AI gateway exists to hold every provider key so your apps don't have to. One compromised LiteLLM proxy can hand over your OpenAI, Anthropic, Google and Bedrock keys at once, plus its database password and cloud role.
In August 2026, Microsoft told defenders to "treat AI gateways as Tier-0 secrets stores", the most sensitive tier, and this payload carried code written specifically to read LiteLLM's credential tables. Prompt injection attacks what an AI system says; this attacked the keys behind it (our prompt injection attack examples cover the other side).
How to check if LiteLLM 1.107.0 is on your systems, step by step
Microsoft advises inspecting a suspect environment from a clean machine, not by running Python inside it. Even LiteLLM's suggested pip show litellm restarts the hooks, so use it only where you're rebuilding anyway.
1. Search lock files. Look for litellm 1.107.0 in uv.lock, poetry.lock, Pipfile.lock and requirements*.txt in every repository.
2. Look for the files. Search every site-packages for cook.pth, cook.py, sitecustomize.py, __pycache__/cook*.pyc and a litellm-1.107.0.dist-info folder. On Linux or macOS, find / -name cook.pth 2>/dev/null is a start.
3. Check temp files and processes. A Python daemon holding /tmp/remote_code_loader.lock means the backdoor is running. The marker /tmp/.pc_once_72129118c59f5636c0bb means data was sent; its absence proves nothing.
4. Check images by digest. Run docker image ls --digests and look for sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e. LiteLLM's post lists separate digests for the litellm-database and litellm-non_root images.
5. Search CI logs and caches. Any job between 11:39 and 14:20 UTC on 21 October that installed LiteLLM unpinned or pulled a floating tag is suspect. Check pip and uv caches and internal mirrors too, because a cached copy can be installed later.
6. Check network logs for connections to www.litellm.dev or the header X-Token: XWiA8gZNa7NcX2XRoAEb.
For the March incident, look for versions 1.82.7 and 1.82.8 and a file called litellm_init.pth.
What to do if you find it: the response checklist
LiteLLM's rule: if 1.107.0 ran on a host with outbound internet access, assume the theft succeeded.
1. Isolate affected laptops, build agents, containers and gateway hosts, and stop automation restarting Python on them.
2. Kill and delete. Stop the daemon holding the lock file and delete cook.pth, cook.py, sitecustomize.py and their .pyc files. Don't run pip install first: it re-runs the hooks.
3. Rebuild, don't repair. Rebuild from scratch, pinned to litellm==1.106.2 or one of the signed releases LiteLLM has shipped since 24 October (such as 1.106.4).
4. Rotate from a clean machine everything the malware could reach (see "What the malware stole" above, and the list below).
5. Hunt for misuse. In AWS CloudTrail, look for GetCallerIdentity and Bedrock listing calls from your roles around install time, and any use of those credentials from outside your network. Check provider dashboards for unexpected spend.
Pay special attention to:
- Cloud: the instance or task IAM role, not just access keys.
- LLM keys: every provider key in the proxy database, plus the master key and all virtual keys.
- Database and GitHub: the DATABASE_URL password and gh CLI logins.
- Sessions: revoke browser and AI tool sessions too; Microsoft notes that rotating a key doesn't always end live sessions.
It's a long list, but it's finite, and every step closes a specific door.
How to stop the next one
After March, LiteLLM had moved to trusted publishing, immutable Docker tags and signed images. The attacker didn't break those controls; they drove the real pipeline with real credentials, and the build provenance honestly recorded Berri-AI/litellm as the source. The answer is layers, not one control.
| Control | What it does | Would it have helped here? |
| --- | --- | --- |
| Lock files with hashes | Installs exactly the files you tested (uv.lock, poetry.lock, or pip install --require-hashes) | Yes, if you were pinned to 1.106.2 |
| Delayed adoption | Ignores releases younger than a set age: uv's exclude-newer = "7 days", pip's --uploaded-prior-to P7D | Yes: both poisoned LiteLLM releases were pulled within hours |
| Image digests, not tags | Runs image@sha256:... instead of latest | Yes: floating tags were repointed at the bad image |
| Check provenance, not just signatures | Confirms the source repository named in the attestation is the real one | Yes: the provenance named the lookalike fork |
| Scoped, short-lived tokens | Uses fine-grained tokens with expiry and OIDC instead of personal CLI logins | Partly: the chain started with a personal owner token and an unrevoked key |
| Egress allowlists | Lets gateway and build hosts reach only the APIs they need | Yes: posts to www.litellm.dev would have failed |
| Two-person release approval | Requires a second human for each release | Partly: that week LiteLLM had relaxed this rule and temporarily widened release permissions |
Three habits matter most:
- Delayed adoption is the cheapest win. Both LiteLLM compromises this year were caught the same day, so a seven-day wait would have skipped both. Keep a fast path for real security fixes.
- Revoke leaked keys; don't just delete the commit. An overwritten commit doesn't make a key useless; only revoking does.
- Fence off the gateway. Give each app its own virtual key with a spend limit, and run the gateway as a non-root container with no other credentials on the host, as in our Docker containerisation work. For CI, our guide to GitHub Actions and AWS OIDC replaces stored keys with short-lived ones.
The npm ecosystem learned the same lessons in 2025; our npm supply chain attacks article covers the JavaScript side.
The March 2026 incident: same project, different route
On 24 March 2026, versions 1.82.7 and 1.82.8 appeared on PyPI from 10:39 UTC and were live for about 40 minutes before quarantine, LiteLLM says. It treats anyone who installed between 10:39 and 16:00 UTC as possibly affected.
That attacker skipped the release pipeline: LiteLLM believes the compromise came through Trivy, a security scanner in its CI, and the packages were uploaded straight to PyPI. Version 1.82.8 used the same trick, a litellm_init.pth startup hook.
Datadog links March to a campaign called TeamPCP and says no connection to October has been established.
Frequently asked questions
Is LiteLLM safe to use now?
As of 26 October 2026, the malicious 1.107.0 has been removed, 1.106.2 is the last version published before the incident, and LiteLLM resumed releases on 24 October (1.106.4 is the latest) with a new image-signing key. LiteLLM says it is revoking every internal token and has removed GitHub permissions from its release workflow. The problem was the release process, not the code, so pin exact versions by hash and wait a few days before upgrading, whichever gateway you use.
What is a .pth file in Python, and why is it dangerous?
A .pth file in site-packages normally just adds folders to Python's import path, but any line in it starting with import runs every time Python starts, whether or not your code uses that package. That turns one tiny file into code that runs in every Python process, and it survives pip uninstall.
Was the LiteLLM Docker image affected?
In October, yes. The images on Docker Hub and GHCR tagged v1.107.0, latest, main-latest and main-stable pointed at a poisoned image from 11:46 UTC until between about 13:51 and 14:20 UTC on 21 October 2026, so anyone running a floating tag could have pulled it without choosing 1.107.0. In the March 2026 incident, the official Docker image was not affected because it pinned its dependencies.
The bottom line
The way into the LiteLLM supply chain attack wasn't clever: a leaked key nobody revoked, a few personal tokens, and a build tool told to copy one extra folder. The defences are just as ordinary: pin by hash, wait before upgrading, run images by digest, scope your tokens and limit where your gateway can talk to. Treat the box that holds every AI key like the box that holds every password, because it is one.
Start today with step 1 of the check above: search your lock files for 1.107.0. Running an AI gateway or a Python build pipeline and not sure how exposed it is? Book a free consultation and we'll go through your dependency pinning, secrets and egress rules with you.
Sources
- LiteLLM, Contained: Oct 21, 2026 LiteLLM release v1.107.0 Compromised (21 October 2026, updated 23 October 2026): docs.litellm.ai
- LiteLLM, The attack chain behind the v1.107.0 LiteLLM release (23 October 2026): docs.litellm.ai
- LiteLLM, Security Update: Suspected Supply Chain Incident (24 March 2026, updated 27 March 2026): docs.litellm.ai
- LiteLLM, Announcing CI/CD v2 for LiteLLM (30 March 2026): docs.litellm.ai
- GitHub, BerriAI/litellm release v1.106.4 (25 October 2026): github.com/BerriAI/litellm/releases/tag/v1.106.4
- GitHub, BerriAI/litellm issue #47826, litellm 1.107.0 compromised (21 October 2026): litellm#47826
- Microsoft Security Blog, Inside the LiteLLM 1.107.0 supply chain compromise: Credential theft through Python startup hooks (23 October 2026): microsoft.com
- Microsoft Security Blog, When AI infrastructure becomes the target: Securing gateways and control points (26 August 2026): microsoft.com
- Datadog Security Labs, LiteLLM supply chain compromise: What you need to know (21 October 2026): securitylabs.datadoghq.com
- Wiz, LiteLLM Compromised, Steals Credentials (21 October 2026, updated 22 October 2026): wiz.io
- Endor Labs, LiteLLM 1.107.0 Compromised: Python Startup Hooks Steal Cloud, AI, and Proxy Credentials (21 October 2026): endorlabs.com
- Python documentation, site: Site-specific configuration hook: docs.python.org
- pip documentation, pip install (--uploaded-prior-to, --require-hashes): pip.pypa.io
- uv documentation, Settings reference (exclude-newer): docs.astral.sh
---
Originally published on znovastudio.com.
1 reaction
【一次資料・GitHub】
■ 実際の不具合報告と再現結果
litellm#48543
■ 私の独自指摘:料金を決める対象と課金すべき操作の区別
litellm#48543
■ 開発者による修正・実接続検証・マージ
litellm#49392
■ メンテナによる承認
litellm#49392
9 likes
LiteLLM 10月28日的补丁修了一个卡请求的细节:原先部分响应在事件循环里做 gzip 压缩,现在只压缩仪表盘统计,交给工作线程处理。
代价也明确:模型推理响应不再压缩,走公网部署时得重新看流量。
BerriAI/litellm
4 likes · 112 views
LambdaとBedrockで自作するLLMプロキシ(キー発行・予算・管理UI)
社内でClaude CodeやCodex CLIを使う人が増えてくると、「誰がどのモデルにいくら使っているかわからない」「キーを配るしくみがない」「危ないツールを禁止したい」という話になります。
LiteLLMを立てれば解決しますが、常時起動のコンテナとDBを運用するほどの規模ではなく、依存パッケージの脆弱性を追いかけ続けるのも避けたいと考えました。
そこでAWS LambdaとAmazon Bedrockで、必要な機能だけを持つLLMプロキシを自前で作ってみました。
できあがったものは、Anthropic形式とOpenAI形式の両方を受け付けるプロキシです。Claude以外のBedrockモデルも同じキーで使え、キーやチームごとに使えるモデルやツール、予算などを絞り、1リクエスト1行のログを残します。
キーの発行や利用量の確認は管理UIとCLIからでき、予算の消化が進むと通知が飛びます。固定費はかからず、コードは依存パッケージなしの6ファイル、インフラはTerraformです。
コードはGitHubで公開しています。
upgradeinc-c-m/simple-llm-proxy
構成を決めるまで
LiteLLMを使わなかった理由
最初の候補はLiteLLMでした。Virtual Keyの発行、キーごとの予算、Admin UIと、欲しいものは一通りそろっています。
ただし本格的に使うにはECSかEC2で常時起動させ、キー管理のためにPostgreSQLも必要です。使わない時間帯にも課金が続き、LiteLLM本体と依存パッケージのアップデートも追い続けることになります。
今回の要件は「ログと最低限の制限、キーの配布と予算」で、ルーティングやフォールバックのような高度な機能は不要でした。
OpenAI形式とAnthropic形式の変換の対応関係は、LiteLLMの実装を参考にしています。
Lambda Function URLにした理由
サーバレスで受け口を作る選択肢を並べました。
構成
固定費
ストリーミング
所感
ECS/Fargate + LiteLLM
常時起動分
可
機能は豊富だが今回の要件には重い
API Gateway + Lambda
なし
REST APIは可、HTTP APIは非対応
認証をプロキシ側で持つならAPI Gatewayの出番が少ない
Lambda Function URL
なし
可(RESPONSE_STREAM)
今回はこれ
決め手はストリーミングです。Claude CodeもCodexもストリーミング前提のため、Function URLのInvokeMode: RESPONSE_STREAMでBedrockのイベントストリームをそのまま流せるのが効きました。
詳細はレスポンスストリーミングのドキュメントにあります。
認証はプロキシの中で独自のAPIキーを見るので、Function URLのAuthTypeはNONEにしています。
公開エンドポイントを守っているのはAPIキーの照合と送信元CIDRだけのため、WAFが必要ならCloudFrontを前へ置き、Function URLをAWS_IAMへ変える構成になります(今回は試していません)。
BedrockにはAnthropic形式をそのまま渡せる
転送先をBedrockにしたのは、Anthropic Messages APIのボディをほぼそのまま受けてくれるからです(Bedrockのドキュメント)。
modelを外してanthropic_versionを足すだけのため、プロキシは認証・制限・ログだけを担当し、本文は透過できます。先に直接投げて確かめました。
aws bedrock-runtime invoke-model \
--model-id jp.anthropic.claude-haiku-4-5-20251001-v1:0 \
--body '{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":"1+1は?数字だけ答えて"}]}' \
--cli-binary-format raw-in-base64-out /dev/stdout
{"model":"anthropic.claude-haiku-4-5-20251001-v1:0","id":"msg_bdrk_...","type":"message","role":"assistant","content":[{"type":"text","text":"2"}],...,"stop_reason":"end_turn",...,"usage":{"input_tokens":21,...,"output_tokens":5,...}}
OpenAI形式(Chat Completions、Responses)はLambdaの中でAnthropic形式に変換してから、同じ経路に流します。
モデルは国内で処理したかったのでjp.の推論プロファイルを使っています。
Claude以外のモデル(NovaやLlama)も使いたかったので、モデルIDにanthropic.を含まないものはBedrockのConverse APIに変換して呼ぶようにしました。
Converseはモデル非依存のAPIで、入力も出力もAnthropic形式に機械的に対応づけられます。入口の形式は3つ、上流は2つですが、間はすべてAnthropic形式で統一しているので、組み合わせが増えても変換は増えません。
キーと利用量はDynamoDBに置く
最初はキー設定をSSMパラメータのJSONに手書きしていました。試すには十分でしたが、人に配るには「発行して渡す」「使いすぎたら停止する」が欠かせません。
そこでDynamoDBに1テーブルを用意し、キーの設定(SHA-256ハッシュで引く)、期間ごとの利用量、モデル単価を置きました。平文のキーは発行時に1回表示するだけで、保存しません。
あとからチーム(所属キー全体の予算とレート)、ロール付きの管理トークン、管理操作の監査ログも同じテーブルに足しています。項目の種類は増えましたが、テーブルは1つのままです。
予算の判定に使う費用はこの単価テーブルから計算します。Bedrockの請求とは丸めや単価改定でずれうるので、請求はCost Explorerで見る前提です。
管理操作はプロキシと同じエンドポイントには置かず、IAM権限で動くCLI(keyctl.py)と、別のFunction URLで動く管理UIに分けました。プロキシ本体のLambdaはキーの作成と削除の権限を持ちません。
環境
リージョンはap-northeast-1、Lambdaランタイムはnodejs22.x。
aws-cli 2.38.5、Terraform 1.17.1(AWSプロバイダ6.71.0)、Node.js 22.23.3。
Claude Code 2.1.309、@anthropic-ai/sdk 1.3.0、openai 7.45.1、Codex CLI 0.165.0。
検証日は2026-10-29〜31。
やってみた
Terraformでデプロイする
リソースはDynamoDBテーブル、ロググループ、IAMロール、Lambda関数、Function URL、ダッシュボード、予算通知用のSNSトピックと、管理UI用のLambda一式です。
コードはarchive_fileでzipにするだけで、ビルドもパッケージ用のS3バケットも不要です。
cd terraform && terraform init && terraform apply
Outputs:
function_url = "xxxxxxxx.lambda-url.ap-northeast-1.on.aws"
admin_url = "yyyyyyyy.lambda-url.ap-northeast-1.on.aws"
dashboard_url = "ap-northeast-1.console.aws.amazon.com"
keys_table_name = "llm-proxy-keys"
log_group_name = "/aws/lambda/llm-proxy"
alert_topic_arn = "arn:aws:sns:ap-northeast-1:<account>:llm-proxy-alerts"
Function URLをauthorization_type = "NONE"で作ると、AWSプロバイダがlambda:InvokeFunctionUrlとlambda:InvokeFunctionの2つの許可を自動で付けてくれます。
CloudFormationで組んでいたときはこの2つ目を書き忘れて全リクエストが403になりました(後述)。CloudFormationならInvokedViaFunctionUrl: trueのAWS::Lambda::Permissionを追加します。
Claude Codeから使う場合は、Bedrockが受け付けるanthropic-betaだけを許可リストに入れておきます。
terraform apply -var 'allowed_betas=context-management-2025-06-27,interleaved-thinking-2025-05-14'
モデル単価を登録してAPIキーを発行する
予算の判定に使う単価を先に入れます。使うモデルの単価を料金ページから転記します。
python3 keyctl.py set-prices 'jp.anthropic.claude-haiku-4-5-20251001-v1:0=1.1:5.5,jp.anthropic.claude-sonnet-4-5-20250929-v1:0=3.3:16.5'
キーはCLIか管理UIで発行します。CLIだとこうなります。平文はこのとき1回しか表示されません。
python3 keyctl.py create --name team-a \
--models 'claude-haiku-4-5=jp.anthropic.claude-haiku-4-5-20251001-v1:0,claude-sonnet-4-5=jp.anthropic.claude-sonnet-4-5-20250929-v1:0' \
--deny-tools Bash,exec_command --cidrs 203.0.113.0/24 \
--budget-usd 50 --budget-period monthly --rpm 60 --tpm 200000 --expires-in-days 90
created key_xxxxxxxxxxxx (team-a)
API key (shown once, store it securely):
llmp_................................................
--modelsはクライアントが送る短い名前とBedrockモデルIDの対応表で、そのまま許可リストにもなります。モデルを差し替えるときにクライアント側の設定を触らずに済みます。
--deny-toolsはリクエストに含まれるツール定義の名前で判定します。大文字小文字を区別するので、Claude CodeならBash、Codexならexec_commandです。
発行後はlistで一覧、usageで期間別の利用量、updateで制限の変更、disableで一時停止、revokeで失効ができます。
平文の漏えいが疑われるときはregenerateで、keyIdと設定と利用量を保ったまま平文だけ作り直せます。旧平文はキャッシュが切れる60秒以内に使えなくなります。
管理UIから発行する場合は、admin_urlを開いてkeyctl.py admin-tokenで作った管理トークンを貼り付けます。
キーごとの状態、モデル、ツール制限、今月のリクエスト数とトークン数、推定費用、予算の消化率が1行で見える
発行直後に平文が1回だけ出る。閉じると二度と取り出せない
Claude Codeからつなぐ
接続先と認証トークンの環境変数を差し替えるだけです。モデル名はプロキシ側で決めたエイリアスを指定します。
export ANTHROPIC_BASE_URL=xxxxxxxx.lambda-url.ap-northeast-1.on.aws
export ANTHROPIC_AUTH_TOKEN=llmp_...
export ANTHROPIC_MODEL=claude-haiku-4-5
export ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5
claude -p "1+1は?数字だけ答えて"
2
ログのtoolsには、Claude Codeがモデルに渡したツール定義の名前が全部並びます。Agent、Bash、Edit、Read、Write、WebFetchにMCPのツール名まで見えるので、プロキシ側からクライアントの顔ぶれがわかります。
ここでBashをdenyしたキーに切り替えると、Claude Codeは起動時に止まります。
Failed to authenticate. API Error: 403 tools not allowed: Bash
Codex CLIからつなぐ
CodexはOpenAIのResponses APIを話すので、/v1/responsesで受けてAnthropic形式に変換しています。~/.codex/config.tomlでプロバイダを差し替えます。
~/.codex/config.toml
model_provider = "llmproxy"
model = "claude-haiku-4-5"
[model_providers.llmproxy]
name = "llm-proxy"
base_url = "xxxxxxxx.lambda-url.ap-northeast-1.on.aws"
env_key = "LLM_PROXY_KEY"
wire_api = "responses"
export LLM_PROXY_KEY=llmp_...
codex exec --dangerously-bypass-approvals-and-sandbox "このディレクトリにあるファイル名を列挙して、ファイル名だけ答えて"
codex
現在のディレクトリのファイルを確認します。
exec
/bin/bash -lc 'ls -1' in /tmp/codex-test
succeeded in 0ms:
sample.txt
#
codex
sample.txt
exec_commandのfunction_call、実行結果の返却、最終回答という往復がプロキシ越しに通りました。
サンドボックスを外すフラグを付けたのは、検証に使ったコンテナでbubblewrapが動かなかったためです。通常のLinuxやmacOSでデフォルトのまま動くかは試していません。
exec_commandをdenyしたキーだと、CodexはERROR: Reconnecting... 1/5から5/5まで再接続を繰り返します。
そのあとでunexpected status 403 Forbidden: tools not allowed: exec_command, write_stdinと出て終了します。Claude Codeのように即座には出ません。
OpenAI SDKからもbaseURLに<Function URL>/v1を指定すれば、Chat CompletionsとResponsesの両方でツール呼び出しの往復とストリームが通りました。
予算とRPMで制限する
小さい上限を付けたキーで、超過時の応答を確かめました。RPM 3のキーで同じ分に5回投げると、4回目から429です。
req1 14:15:35: content="Hello! " HTTP 200
req2 14:15:43: content="# Hey! " HTTP 200
req3 14:15:43: content="Hey! " HTTP 200
req4 14:15:44: {"error":{"message":"rpm limit exceeded for key (3 requests per minute)","type":"rate_limit_error","param":null,"code":null}} HTTP 429
req5 14:15:44: {"error":{"message":"rpm limit exceeded for key (3 requests per minute)","type":"rate_limit_error","param":null,"code":null}} HTTP 429
日次予算を0.00005ドルにしたキーは、1回目(費用0.000053ドル)で予算を超え、2回目から429になりました。
{"type":"error","error":{"type":"insufficient_quota","message":"budget exceeded for key (spent 0.000053 of 0.00005 USD daily)"}}
有効期限切れと無効化は403、失効したキーは401です。ただしキー設定はLambdaコンテナごとに60秒キャッシュしているので、失効直後の1分間は通ります。
判定はBedrockを呼ぶ前にその時点の累計で行い、利用量の加算は応答が終わってからです。並列にリクエストが来ると上限を多少超えることがありますが、ここは割り切りました。
Claude以外のモデルを同じキーで使う
モデルの対応表にNovaを足すだけです。IDにanthropic.が含まれないので、プロキシはConverse APIで呼びます。クライアント側はmodelを変えるだけで、Anthropic形式とChat Completionsのどちらからも同じように動きました。
python3 keyctl.py update key_xxxxxxxxxxxx --models 'claude-haiku-4-5=jp.anthropic.claude-haiku-4-5-20251001-v1:0,nova-lite=apac.amazon.nova-lite-v1:0,titan-embed=amazon.titan-embed-text-v2:0'
curl "$URL/v1/chat/completions" -H "authorization: Bearer $KEY" -d '{"model":"nova-lite","messages":[{"role":"user","content":"東京の天気を教えて"}],"tools":[...]}'
{"choices":[{"message":{"role":"assistant","content":"<thinking> ... </thinking>\n","tool_calls":[{"id":"tooluse_...","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"東京\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":398,"completion_tokens":59,...}}
Novaはツール呼び出しの前に<thinking>を本文に書く癖がありますが、それはモデルの挙動なので手を入れていません。モデルを足すたびにプロキシを触らずに済むのは、途中をAnthropic形式で統一しておいた効果でした。ストリーミングもResponses APIも同じ経路で通りました。
埋め込みは/v1/embeddingsで受け、TitanかCohereのモデルに流します。Titan v2で2件入力すると1,024次元のベクトルが2本返りました。
モデルの対応表はalias=modelA|modelBと書くとフォールバックになります。1本目がスロットリングや5xx、またはモデルIDの無効化(廃止)で失敗したら2本目を試し、ログにfallbackToを残します。
存在しないIDを1本目に置いて試すと、2本目のNovaで200が返りました。
チームで予算をまとめる
個人のキーに加えて、チーム単位の上限が欲しくなります。チームを作って予算とRPMを持たせ、キーを所属させると、キー自身の制限とチーム合計の両方で判定します。
python3 keyctl.py team create --name platform --budget-usd 200 --budget-period monthly --rpm 300
python3 keyctl.py create --name alice --team team_xxxxxxxxxxxx --models '...'
python3 keyctl.py team usage team_xxxxxxxxxxxx
RPMはDynamoDBの条件付き書き込みで数えています。分ごとの項目にreservedを加算するとき、reserved < rpmを条件にして、失敗したら429です。
チームRPMを2にして、2本のキーから同時に6リクエスト投げると、200が2件、429が4件で、並列でも上限を超えませんでした。
k2 200
k2 429
k1 429
k2 429
k1 429
k1 200
{"type":"error","error":{"type":"rate_limit_error","message":"rpm limit exceeded for team test-team (2 requests per minute)"}}
予算はその時点までの累計で見るので、ストリーミングの途中で超えた分は次のリクエストから効きます。
チームの一覧と監査ログ。チームの行には所属キー数と今月の合計が出る
予算の消化を通知する
429になって初めて気付くのは不親切なため、予算の80%と100%へ達したときにSNSへ1回だけ通知するようにしました。
利用量を加算したあと、alerted80やalerted100のフラグを条件付き書き込みで立て、立てられたときだけPublishします。並列でも通知は1回です。
日次予算0.00006ドルのキーに3回投げたときの結果です。SNSのメトリクス上の発行数も2件でした。
1回目: 200。消化率88%になり、80%の通知。
2回目: 200。消化率176%になり、100%の通知。
3回目: 429。
{"alert":"[llm-proxy] key \"alert-test\" reached 80% of budget"}
{"alert":"[llm-proxy] key \"alert-test\" reached 100% of budget"}
メールで受け取るならTerraformのalert_emailを設定します。購読確認のメールが届くので、そこだけ手作業です。
Guardrailsをキーに付ける
Bedrock GuardrailsのIDとバージョンをキーの設定に持たせると、Claude(InvokeModel)とNova(Converse)の両方へ同じガードレールが付きます。
python3 keyctl.py update key_xxxxxxxxxxxx --guardrail abcd1234efgh:2
語句フィルタに社外秘を入れたガードレールで試すと、どちらの経路でも本文がブロック文言に置き換わりました。
Nova(Converse)をChat Completions形式で呼ぶとfinish_reasonはcontent_filterになります。Claude(InvokeModel)ではstop_reasonはend_turnのままで、本文だけが置き換わります。
{"id":"bedrock-guardrails-...","content":[{"type":"text","text":"このリクエストはガードレールでブロックされました。"}],"stop_reason":"end_turn",...}
管理トークンにロールを付けて操作を記録する
管理UIのトークンは複数発行でき、viewerロールは参照だけです。POSTやDELETEは403で返ります。
管理APIで何かを変えるたびに、誰が・いつ・何を・どのキーに対して行ったかを監査ログとして同じテーブルに残し、UIとCLIの両方から月ごとに見られます。
python3 keyctl.py admin-token --name auditor --role viewer
python3 keyctl.py audit --month 2026-10
2026-10-31T13:55:44.734Z ops POST /api/keys/key_adef5aa8dbc0/regenerate key_adef5aa8dbc0 {}
利用者が自分の利用量を確認したいときは、プロキシのGET /v1/usageに自分のキーで問い合わせます。自分とチームの日・月の利用量、上限、有効期限が返るので、管理者に聞かなくて済みます。
利用状況を見る
ログが1リクエスト1行のJSONのため、Logs Insightsのクエリをそのまま並べたダッシュボードをTerraformで作っています。
{"ts":"2026-10-31T01:20:55.032Z","requestId":"...","sourceIp":"203.0.113.10","method":"POST","path":"/v1/chat/completions","format":"chat","keyName":"limits-test","keyId":"key_14a5527775be","model":"claude-haiku-4-5","stream":false,"tools":[],"modelId":"jp.anthropic.claude-haiku-4-5-20251001-v1:0","costUsd":3.1e-05,"decision":"allow","inputTokens":8,"outputTokens":4,"stopReason":"max_tokens","cacheReadTokens":0,"cacheWriteTokens":0,"status":200,"latencyMs":661}
キー別の件数とトークン数、拒否理由、推移、レイテンシ、キー別の費用、制限による拒否、渡されたツール名。制限のテストで出した429や403がそのまま並ぶ
コンソールを開かずに共有したいときはusage-report.pyで同じ集計をMarkdownにします。AWS CLIの認証情報だけで動きます。
python3 usage-report.py --hours 24 --region ap-northeast-1
実装のポイント
コードはプロキシ本体が4ファイルです。index.mjsはルーティングと認証と転送、keys.mjsはキーとチームの照合・制限判定・費用計上・アラートを担当します。
openai.mjsはOpenAI形式の変換、converse.mjsはConverse APIとの変換です。管理UIはadmin.mjsとadmin-ui.mjsの2ファイルです。
AWS SDK v3はLambdaのNode.jsランタイムに同梱されているので、依存パッケージはありません。全体はリポジトリを見てもらうとして、要点だけ書きます。
モデル・ツール・CIDRの判定はこれだけです。拒否理由をそのままクライアントへ返し、ログにも残します。
src/index.mjs(抜粋)
function authorize(keyConfig, sourceIp, alias, toolNames) {
if (keyConfig.cidrs?.length && !keyConfig.cidrs.some((c) => ipInCidr(sourceIp, c))) {
return { status: 403, type: "permission_error", message: source ip ${sourceIp} is not allowed };
}
if (!keyConfig.models?.[alias]) {
return {
status: 403,
type: "permission_error",
message: model "${alias}" is not allowed. allowed: ${Object.keys(keyConfig.models ?? {}).join(", ")},
};
}
const { allow, deny } = keyConfig.tools ?? {};
const blocked = toolNames.filter((n) => (allow && !allow.includes(n)) || (deny && deny.includes(n)));
if (blocked.length) {
return { status: 403, type: "permission_error", message: tools not allowed: ${blocked.join(", ")} };
}
return null;
}
DynamoDBのテーブルの主な項目は次のとおりです。種類は増えましたが、どれもpkの接頭辞で見分けられます。
pk
sk
内容
KEY#<sha256(key)>
META
名前、所属チーム、モデル、ツール、CIDR、予算・RPM・TPM、Guardrail、有効期限、無効化
KEY#<sha256(key)> / TEAM#<teamId>
USAGE#min#… / day#… / month#…
リクエスト数、RPM用の予約数、トークン数、費用(マイクロドル)、通知済みフラグ。分の項目はTTLで消える
TEAM#<teamId>
META
チーム名と予算・RPM・TPM
ADMIN#<sha256(token)>
META
管理トークンの名前とロール
AUDIT#<yyyy-mm>
<時刻>#<乱数>
管理操作の記録(操作者、操作、対象)
CONFIG
PRICES
モデルIDごとの単価(100万トークンあたりUSD)
利用量制限はこのテーブルから当該分と当該期間の項目を読んで比べます。RPMだけは読んで比べるのではなく、分の項目に予約数を条件付きで加算して、失敗したら429にしています。超過はBedrockを呼ぶ前に返すので、無駄なトークン課金は起きません。
src/keys.mjs(抜粋)
// RPM: 分の項目に reserved を条件付きで加算する(並列でも上限を超えない)
for (const s of scopes) {
if (!s.limits.rpm) continue;
try {
await ddb.send(new UpdateItemCommand({
TableName: TABLE, Key: marshall({ pk: s.pk, sk: USAGE#${p.minute} }),
UpdateExpression: "ADD reserved :one SET #ttl = if_not_exists(#ttl, :ttl)",
ConditionExpression: "attribute_not_exists(reserved) OR reserved < :rpm",
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: marshall({ ":one": 1, ":rpm": s.limits.rpm, ":ttl": Math.floor(now.getTime() / 1000) + 3600 }),
}));
} catch (e) {
if (e.name === "ConditionalCheckFailedException") {
return { status: 429, type: "rate_limit_error", message: rpm limit exceeded for ${s.label} (${s.limits.rpm} requests per minute) };
}
throw e;
}
}
Converseへの変換で気を付けたのは、ストリームの形です。ConverseStreamはテキストブロックの開始イベントを送らないので、最初のdeltaが来たときにcontent_block_startを補ってからAnthropicのイベント列に戻します。これで後段のChat CompletionsやResponsesへの変換には手を入れずに済みました。
ストリーミングは、Bedrockから届くチャンクをJSONとして読み、Anthropic形式の入口ならevent: <type>\ndata: <json>\n\nに組み直すだけです。
OpenAI形式の入口では、同じイベントをchat.completion.chunkやResponses APIのresponse.output_text.deltaなどに変換して流します。
awslambda.streamifyResponseとawslambda.HttpResponseStream.fromはランタイムが提供するグローバルで、importは不要です。
管理UIは別のLambdaで、HTML(約23KB)をJavaScriptのテンプレート文字列として同梱し、同じ関数が/api/*でJSONを返します。フレームワークもビルドもCDNも使っていません。
管理トークンはDynamoDBにハッシュだけを置いて定数時間で比較し、Terraformのadmin_cidrsで送信元を絞れます。SSOで守りたい場合はCognitoなどを前へ置く構成に差し替えます。
1リクエストの流れをまとめると次の図になります。
Claudeを呼ぶときの流れ。Claude以外ではInvokeModelの部分がConverseに変わる
対応しているルートは、会話用の/v1/messages、/v1/chat/completions、/v1/responsesと、埋め込みの/v1/embeddingsです。
補助として/v1/messages/count_tokens、/v1/models、/v1/usage、/healthがあります。
未対応なのは、画像生成や音声、バッチのエンドポイントと、OpenAI側のprevious_response_id、response_formatのtext以外、n > 1、ホスト型ツールです。
キーの一覧とkeyIdからの検索はテーブルのScanで済ませているので、キーが数千件を超えるならGSIを足すことになります。
ハマったところ
Function URLが全リクエストで403を返す
最初はCloudFormationで組んでいました。デプロイ直後は、匿名リクエストもIAM署名付きリクエストも次の応答しか返しません。
{"Message":"Forbidden. For troubleshooting Function URL authorization issues, see: docs.aws.amazon.com"}
aws lambda invokeで直接呼ぶと関数は正常で、CloudWatchにログストリームもありません。URLの手前で止まっています。
リソースポリシーにはlambda:InvokeFunctionUrlを付けてあり、ドキュメントどおりに見えました。
原因は、同じアカウントでコンソールから作った別のFunction URLのポリシーと見比べてわかりました。そちらにはlambda:InvokeFunction(条件lambda:InvokedViaFunctionUrl: true)という2つ目のステートメントがあります。
CloudTrailにはInvokeFunctionUrlの失敗が記録されない(データイベントはデフォルトで記録されない)ので、手元に「動いているFunction URL」がないと気付きにくいです。Terraformのaws_lambda_function_urlはこの2つを自動で付けてくれます。
anthropic-betaヘッダをそのまま渡すとBedrockが400を返す
最初はクライアントのanthropic-betaヘッダをBedrockのanthropic_betaにそのまま渡していました。curlとSDKでは問題なかったのですが、Claude Codeの初回リクエストでBedrockが400を返します。
ValidationException: Unexpected value(s) advanced-tool-use-2025-11-20 for the anthropic-beta header.
Claude Codeはinterleaved-thinking-2025-05-14やadvanced-tool-use-2025-11-20など5つのbetaを送ってきて、Bedrockの知らない値が1つでも混ざると丸ごと400です。
環境変数の許可リストと一致する値だけ渡し、落とした値はdroppedBetasとしてログへ残すようにしました。許可リストを空にすると今度はcontext_management: Extra inputs are not permittedになります。betaを落としてもボディ側のcontext_managementは残るためです。
Claude Codeは400を受けると該当機能を外して自動で再試行するので動作はしますが、毎回エラーログが1行残ります。
context-management-2025-06-27とinterleaved-thinking-2025-05-14を許可すると初回から200で通りました。どのbetaをBedrockが受けるかはモデルと時期で変わるので、droppedBetasを見ながら増やす運用にしています。
OpenAI経路でanthropic_versionを付け忘れた
OpenAI互換を足した最初のデプロイで、Chat CompletionsもResponsesもすべてValidationException: anthropic_version: Field required(400)でした。Anthropic形式の入口だけがこのフィールドを付けていたからです。
Bedrockへ送る直前で共通して付けるよう直しましたが、この400を受けたCodexはリトライを続け、180秒のタイムアウトまで終わりません。原因に気付くまで少し時間がかかりました。
Codexが送るnamespaceとweb_search
Codexをつないだ直後、プロキシがunsupported tool types: namespace, web_searchを返しました。ローカルのダミーサーバに向けてリクエストを捕まえると、ツール定義にfunction以外の2種類が混ざっています。
namespaceは複数のfunctionをまとめた入れ物のため、中身を平坦に展開します。web_searchはOpenAIのホスト型ツールで提供できないので、落としてログへ残すようにしました。
Codex側のweb_search = "disabled"も試しましたが、0.165.0ではツール定義は送られてきたままでした。
上流のエラー文にIAMロールのARNが入っていた
count_tokensの検証で403が返ったとき、クライアント側にBedrockのエラー文がそのまま見えました。
文面はUser: arn:aws:sts::<アカウントID>:assumed-role/... is not authorized to perform: bedrock:CountTokensです。実行ロールのARNとアカウントIDが含まれています。
原因はBedrockのエラーメッセージを転送していたことでした。403と5xxは詳細をログにだけ残し、クライアントには「upstream access denied (check the IAM policy)」のような一般的な文言を返すように変えました。
count_tokensは推論プロファイルIDを受け付けない
/v1/messages/count_tokensをBedrockのCountTokensに流したところ、まずIAMにbedrock:CountTokensがなくて403でした。
次にjp.付きの推論プロファイルIDでは「The provided model doesn't support counting tokens」と返ります。
接頭辞を外した基盤モデルIDで呼ぶと通りましたが、今度はmax_tokens: Field requiredです。数えるだけなのに必須のため、省略時は1を補っています。
Guardrailsのトピック拒否が効かない
日本語で「特定の株式や暗号資産の売買を勧める投資助言」を拒否トピックにしたガードレールは、ApplyGuardrailで直接試してもaction: NONEでした。モデル自身が投資助言を断るので、一見ブロックされたように見えて紛らわしいところです。
プロキシ経由の配線を確かめたかったので、確実に効く語句フィルタに切り替えて検証しました。トピック拒否の精度は定義文と例文の書き方で変わるため、本番で使うなら先にApplyGuardrail単体で当たり方を確かめたほうがよさそうです。
キー一覧にチームと管理トークンが混ざる
キー一覧はsk = METAでScanしていたので、同じテーブルに足したチームと管理トークンの項目まで一覧に出てきました。begins_with(pk, "KEY#")を条件に足して直しています。1テーブルに種類の違う項目を同居させるときの定番の落とし穴でした。
Function URLの末尾スラッシュ
管理UIの/api/*が全部404でした。Terraformのfunction_url出力は末尾に/が付くので、/api/keysをつなぐと//api/keysになります。
ハンドラは末尾のスラッシュしか正規化しておらず、startsWith("/api/")に引っかかりませんでした。連続するスラッシュもまとめるようにして解決です。
ダッシュボードの遅延描画
ヘッドレスブラウザでダッシュボードを確認していて、最下段のウィジェットだけ「No data found」のままでした。クエリの不具合だと思って書き直しましたが、原因は画面外のウィジェットがスクロールするまで描画されないことでした。コンソールで確認するときは最下段までスクロールしてから判断します。
料金
プロキシ自体の費用だけを見積もります。Bedrockのトークン課金は使うモデルと使い方で決まるので、ここでは扱いません。
単価は2026-10-30時点のPrice List APIから取り、計算はリポジトリのcost-estimate.pyでしました。
項目
単価(ap-northeast-1)
Lambdaリクエスト / 実行時間
$0.20 / 100万、$0.0000166667 / GB秒(月100万リクエストと40万GB秒まで無料)
CloudWatch Logs
取り込み$0.76 / GB、保存$0.033 / GB月
DynamoDB(オンデマンド)
書き込み$0.715 / 100万、読み込み$0.1425 / 100万、保存25GBまで無料
1リクエストあたりLambda 3秒×256MB、ログ700バイト、DynamoDB読み込み1単位・書き込み3単位と置いた試算です。
月間リクエスト
Lambda
CloudWatch Logs
DynamoDB
合計
1万
$0.00
$0.01
$0.02
$0.03
10万
$0.00
$0.05
$0.23
$0.28
100万
$5.83
$0.52
$2.29
$8.64
月10万リクエストでも30セント弱で、Lambdaの無料枠に収まります。なお2025年7月以降に作成したアカウントは無料利用枠がクレジット制のため、常時無料の枠が同じ形で使えるかはアカウントの種類で変わります。
CloudWatchダッシュボードはアカウントで3枚まで無料、4枚目以降は1枚$3/月で、create_dashboard = falseで外せます。管理UIのLambdaは管理者が開いたときだけ動くので、こちらも無料枠内です。
SNSはAPIリクエストが月100万件、メール通知が月1,000件まで無料枠があり、予算通知の費用は0円とみなせます。
Guardrailsを付ける場合はポリシーの種類ごとに別途課金があります。Price List APIの東京の単価では、語句フィルタは無料、トピック拒否とコンテンツフィルタは1,000テキストユニットあたり$0.15でした。
まとめ
Lambda Function URLとBedrockにDynamoDBを1テーブル足すだけで、キーの発行、予算、通知、管理UIまでそろいました。Claude以外のモデルもConverse APIへの変換で同じキーから使えます。
LiteLLMにあってこちらにないものは、応答キャッシュ、ルーティングの重み付け、管理UIのSSOです。キャッシュとSSOはRedisやCognitoのような常時動く基盤が前提になるので、固定費ゼロの範囲では持たないと決めました。重み付けは、フォールバックで足りる規模だったので見送っています。
作り込まれた機能は当然ありませんが、固定費ゼロで、追いかける依存もない状態を維持できるのは小さなチームには合っていると感じました。
予算の判定は事後計上で厳密ではないので目安として使い、請求はCost Explorerで確認するのがよさそうです。どなたかの参考になれば幸いです。
【一次資料・GitHub】
■ 利用額の誤記録と予算管理への影響
litellm#48937
■ 私が指摘した「0円設定」の盲点
litellm#48953
■ 開発者による対応の明言
litellm#48953
■ 実際のコード変更・追加テスト・マージ
litellm#48953
9 likes
The LiteLLM MCP auth bypass, and the one request that shows it
Originally published on mcpscore.dev.
On 2 September 2026, CISA added CVE-2026-59822 to its Known Exploited Vulnerabilities catalog. LiteLLM’s MCP endpoint let anyone in who sent a bearer token, and any token would do. The flaw is in LiteLLM’s code, but the symptom is one any MCP server can have, and one request from outside shows it. mcpscore 1.25.0 now sends that request.
What went wrong in LiteLLM
LiteLLM is a gateway: it sits between applications and model providers, and between agents and MCP servers. Its MCP endpoint checks the Authorization header against LiteLLM’s own API keys. Some upstream MCP servers, Atlassian among them, expect their own OAuth token in that same header, so LiteLLM added a fallback: if the header is not a LiteLLM key, treat it as a token for the upstream server and pass it through.
The fallback ran for every server, not only the ones configured for OAuth. In outline, per the advisory:
# The shape of the bug, simplified from the advisory
try:
identity = await check_litellm_key(authorization)
except AuthError:
# Maybe it is an OAuth token for an upstream server.
identity = AnonymousUser() # every server, not only OAuth ones
A failed key check turned into an anonymous session, so a made-up header could list and call every tool behind the gateway and reach the services connected through it. The fix in 1.84.0 runs the fallback only when every server the request targets is configured for OAuth, and fails closed when the target is unknown.
Why patching to 1.83.9 is not enough
Two more LiteLLM CVEs made the security news in October, and the widely shared advice was to upgrade to at least 1.83.9. That advice is right for those two and leaves the MCP bypass open: CVE-2026-59822 affects every release before 1.84.0, so 1.83.9 through 1.83.14 are still exposed.
Recent LiteLLM CVEs, the release that fixes each, and whether it involves MCP
| CVE | Flaw | Fixed in | CISA KEV | MCP |
| --- | --- | --- | --- | --- |
| CVE-2026-59822 | MCP endpoint accepts any bearer token | 1.84.0 | Yes | Yes |
| CVE-2026-59823 | Server-side request forgery via user_config | 1.83.9 | No | No |
| CVE-2026-35029 | Privilege escalation via the config endpoint | 1.83.0 | No | No |
The two newer CVEs are not about MCP, and no outside audit can see them. The third is the one CISA has evidence of attackers using, and its symptom is visible from outside.
What the spec requires
The MCP authorization spec has said the same thing since 2025-06-18, in Token Handling, and the 2026-07-28 release keeps it word for word:
> Invalid or expired tokens MUST receive a HTTP 401 response.
A server that serves a request carrying a token it never issued has not just answered with the wrong status code. Its auth check did not run, or ran and was ignored. Either way, the cause is inside the server, but the result shows on the wire.
The check: one made-up token
The new rule, auth_invalid_token_rejected, runs on any HTTP server that refuses a request without a token. mcpscore sends the same request again, a tools/list, with Authorization: Bearer mcpscore-invalid-token- followed by a random UUID. No real server issued that token, so the only correct answer is a refusal.
- Fail: any 2xx. The server served the request, and the rule is HIGH, worth three points.
- Pass: 401, 403, or another 4xx whose WWW-Authenticate header carries error="invalid_token" (RFC 6750 §3.1). A refusal other than 401 passes with a note, since it misses the MUST without letting anyone in.
- Skip: anything else, such as a 404 to both requests, a redirect, a rate limit or a 5xx.
The second pass condition is there because of a real server. GitHub Copilot’s MCP server answers a malformed token with HTTP 400 and error="invalid_token", and a token shaped like a real GitHub token with 401. Both are refusals, and failing the first at HIGH would have been a false positive.
The rule only reads. It sends no tools/call, follows no redirect with the token, and if the server opens a session anyway, closes it with a DELETE. If the first answer is an unexplained 4xx, as some older servers give to a request without a session, it repeats the check with an initialize, and counts that only when the same initialize without a token is refused.
See it fail
Our acceptance suite has a test server with the bypass built in. It answers a request without a token with a proper 401 challenge and protected resource metadata, and serves any non-empty bearer token. This is the audit of that server, with no token passed:
uvx mcpscore==1.25.0 'http://127.0.0.1:8094/mcp'
Server requires authentication — running a partial audit of the observable surface.
...
✅ The observed HTTP 401 response carries a non-empty WWW-Authenticate challenge.
...
❌ A fabricated bearer token was accepted: tools/list answered HTTP 200, where the same request without a token gets HTTP 401. Observed HTTP status: 200.
Fix: Validate every bearer token (signature or introspection, expiry, audience) before handling the request, and answer an invalid one with HTTP 401. A failed check must never fall back to a default identity.
...
Audit finished. PARTIAL score: 13/27 from 10 of 92 checks — not comparable to a full audit.
Without credentials, mcpscore runs a partial audit of what a server behind auth shows to anyone. The new check belongs there: it needs no token, because the point is what happens to a bad one. Switch the test server to accept only its real token and the check passes, with the three points back:
# After: only the server's own token is served
✅ A fabricated bearer token was refused with HTTP 401.
...
Audit finished. PARTIAL score: 16/27 from 10 of 92 checks — not comparable to a full audit.
Before release we ran the rule against the auth-gated servers in our live acceptance set, among them Asana, Linear, Notion, Sentry, Stripe and GitHub Copilot. All of them refused the token.
What the check cannot tell you
A refused made-up token is necessary, not sufficient. Behind the front door, a server can still accept a real token issued for a different service, forward the client’s token to an upstream API, or grant more scope than the token carries. The spec forbids all three, and none of them is visible from outside. Code review and the gateway in front of the server are where those get caught.
The check also needs a refusal to compare against. If your server answers a request without a token with a 500, mcpscore cannot tell it is behind auth, and the rule does not run. Fix that first: a server behind auth should answer a missing token with 401 and a WWW-Authenticate challenge, which mcpscore checks too.
Gateways are where one bug reaches every server
A gateway holds model-provider keys, upstream tokens and access policy for every application behind it, which is why it is worth putting there. It is also why a single fallback in its auth path exposed every connected MCP server at once. Treat LLM and MCP gateways like the API gateways and identity systems they have become: patch them on the advisory, keep them off the public internet unless they need to be there, and check the auth on the gateway and on each server behind it, since a request that gets past one should still be refused by the other.
Check your server
The rule runs on every HTTP audit in mcpscore 1.25.0, on the CLI and on mcpscore.dev. Point it at the URL you give clients, without a token:
uvx mcpscore==1.25.0 'https://your-server.example/mcp'
Add --sarif to put the finding in GitHub code scanning. The token check covers part of MCP07, insufficient authentication, in the OWASP MCP Top 10; the Origin check covers another. Every rule and its citation is in the rules reference, and the release notes list what changed.
> Does your server refuse a token it never issued?
>
> One command, no credentials: mcpscore sends a made-up bearer token and reports whether your server lets it in.
>
> Auditing servers behind auth
1 reaction
LiteLLM: Privilege Escalation to Proxy Admin via Cross-Domain Reuse of the Salt Key
1 like
Show HN: Better Backlog: Filter GitHub issues with decision models
1 point · 2 comments
Two Open-Source Cloud Agents. One Is an API, One Is a Room
Two Open-Source Cloud Agents. One Is an API, One Is a Room
Source: MiniMax-AI/OpenAgentCore BerriAI/moyai
License: MIT + MIT
Made with AI-assisted production (script, voice, diagrams). No real people depicted. Not affiliated with the project owners. Claims from the project README; demos not re-run by us.
#NoShortAnswers #AIAgents #OpenSource #CodingAgents #Shorts
1 comment · 38 views
How a Key Leaked in May Shipped Malware in October
---
title: "How a Key Leaked in May Shipped Malware in October"
published: true
description: "The LiteLLM attack had no clever trick in it. Every step was a key or token that still worked long after anyone needed it. And the malware ran before Python imported anything."
tags: security, python, devops, ai
cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com
---
On 27 May 2026, someone committed a config file to a public repository by mistake. Inside it was the master key — the admin password, in effect — for one of LiteLLM's internal services. They rewrote the git history to hide it, and everyone moved on. Nobody switched the key off.
It was first used on 10 October. That is 136 days later.
Read what follows slowly, because nothing in it is clever. No bug, no zero-day. Every step is a key or token that still worked long after anyone needed it.
The chain
LiteLLM is an open-source gateway: one service your apps talk to instead of calling OpenAI, Anthropic, Bedrock and the rest directly. It routes requests, tracks spending and manages keys — which also means it holds every AI provider key a company owns.
The leaked key unlocked an internal tool for letting AI agents open pull requests. When the attacker called it, the tool handed back a GitHub token belonging to an account that owned the whole organisation. Nobody had limited what that token could do. LiteLLM's write-up says it "was not associated with a service account and was not scoped down." It could do anything, anywhere in their GitHub.
With that token, the attacker pushed a throwaway job into LiteLLM's main repository. GitHub Actions jobs can read the repository's secrets. So this one read them and posted them to a URL the attacker controlled: six, including a second maintainer's personal token. A second job took a third maintainer's. Both were deleted minutes later. Two days on, another round took fourteen more from the private repositories, including Anthropic, OpenAI and Bedrock keys.
!Six-step chain from a key leaked on 27 May to a malicious release on 21 October, with no exploit at any step
Each step is a credential that outlived its purpose. Source: LiteLLM postmortem, 23 Oct 2026.
Then they waited nine days.
On 21 October they pointed LiteLLM's release pipeline at a fake copy of the repository: Berri-AI/litellm, with a hyphen, instead of the real BerriAI/litellm. Branch protection blocked the change. The owner token overrode it.
Publishing a release takes two people: one to start it, one to approve. The approver is normally a locked-down account tied to an office machine. That rule had been relaxed days earlier so any two engineers could do it. The attacker held three maintainers' tokens, so they started the release with one and approved with another — LiteLLM thinks to keep the audit log looking ordinary.
The poisoned version went up on PyPI and Docker. A researcher spotted it twenty-seven minutes later. PyPI pulled it within two hours.
The part that catches people
The bad package installed three files. One is cook.pth, and that extension is the whole story.
A .pth file in site-packages normally just lists extra folders for Python to search. But it has an old compatibility feature almost nobody thinks about. If a line in that file begins with import, Python runs it. Not when you import the package — at startup. The docs say these lines "are always run at Python startup... regardless of whether a particular module is actually going to be used."
!Two flows compared: the expected one where malicious code runs only on import, and the real one where the site module executes the .pth import line at interpreter startup
A .pth line beginning with import is run by Python's site module at startup. Source: Python docs, site.
So the stealer never waited to be imported. It ran whenever any Python process started on that machine: your script, a test run, an unrelated project.
Including this one:
pip install --upgrade litellm
pip is itself a Python program. Running it starts Python, which runs the stealer again, before the upgrade fixes anything.
<!-- YOUR STORY (optional) -->
What to do this week
- Find the files without starting Python: find / -name 'cook.pth' -o -name 'sitecustomize.py' 2>/dev/null. Delete them and any matching .pyc files before you run pip.
- Assume everything on that machine is gone, not just the AI keys. The stealer read cloud credentials, SSH keys, browser data, your database URL, and the config files for Codex, Claude, Gemini and Cursor.
- Pin container images by digest, not tag. latest, main-latest and main-stable all pointed at the bad build.
- Go find keys nobody needs any more. That is the root cause, and the only item here you can act on before an incident rather than after.
Where this breaks down
Calling this "just credentials" undersells one thing. The relaxed two-person rule mattered. Had the approver still been that locked-down office machine, three stolen tokens would not have been enough. The control was doing real work, right up to the moment someone loosened it for convenience.
LiteLLM has said some of its early updates were wrong and more changes are coming, so treat this timeline as the best account so far.
Takeaway
Revoking a leaked key is not cleanup you do later. It is what decides whether a mistake in May becomes malware in October.
What is the oldest credential still working in your infrastructure, and would you know if someone used it?
Sources
- LiteLLM postmortem: v1.107.0 compromise attack chain — 23 Oct 2026
- GitHub issue #47826, Upwind research — 21 Oct 2026
- Socket: attack timeline, workflows 11 days earlier — 23 Oct 2026
- Python docs: the site module and .pth files
5 reactions
Mentions
LiteLLM Supply Chain Attack: How Poisoned Version 1.107.0 Stole AI Keys, and How to Check Your Systems On 21 October 2026, a poisoned release of LiteLLM, the open-source gateway many teams put between their apps and OpenAI, Anthropic or Google, sat on PyPI and Docker Hub for about two hours, long enough for roughly 49,000 downloads. It stole whatever keys the machine could reach, and it ran even if your code never imported LiteLLM. This LiteLLM supply chain attack is the project's second of 2026. Below: a quick self-check, the timeline, what to rotate and the build habits that stop the next one. Most of those habits live in your pipeline, which our CI/CD pipelines work sets up. Am I affected by the LiteLLM attack? The 30-second answer You're at risk if any environment installed litellm 1.107.0, or ran a LiteLLM Docker image on a floating tag, on 21 October 2026 during the roughly two-hour window. The malware ran whenever Python started, so if that machine had internet access, assume the keys it could reach were stolen. Pinning 1.106.2 or earlier kept you out of this incident. You're probably affected if: - An environment installed litellm==1.107.0 from PyPI on 21 October 2026, roughly between 11:39 and 13:23 UTC. - A container ran ghcr.io/berriai/litellm or the Docker Hub image with the tag v1.107.0, latest, main-latest or main-stable, pulled between 11:46 and 14:20 UTC that day. - A dependency pulled in LiteLLM unpinned and got 1.107.0. LiteLLM says its private enterprise registry was not touched. The LiteLLM supply chain attack, minute by minute This timeline follows LiteLLM's canonical record of 23 October, which corrected early assumptions it called "inaccurate or incomplete" (times UTC). | When | What happened | | --- | --- | | 27 May 2026 | A master key for LiteLLM's experimental Agent Platform is committed to a public repository in an opencode.json file. The commit is overwritten; the key is never revoked. | | 10 October, 21:27 | Using that key, the attacker gets the Agent Platform API to return a GitHub organisation owner's GitHub CLI token. | | 10 October, 22:11 | With the owner's token, the attacker runs workflows that post CI secrets, including GitHub tokens, to webhook.site, then deletes the runs. A second round follows on 12 October. | | 21 October, 10:08 | The owner's token overrides branch protection on the private release workflow. | | 21 October, 10:46 | A build runs from a lookalike repository, Berri-AI/litellm (the real one is BerriAI/litellm). | | 21 October, 11:11 | A maintainer's stolen token approves the production release gate. | | 21 October, 11:39:51 | Seven malicious litellm==1.107.0 wheels go up on PyPI over 22 seconds. | | 21 October, 11:46:56 | Poisoned images are published to Docker Hub and GHCR. | | 21 October, 11:53 | Another stolen credential deletes the workflow records. | | 21 October, 12:06 | A community member opens GitHub issue #47826 reporting the compromise. | | 21 October, by 13:23 | PyPI quarantines 1.107.0. | | 21 October, 14:20 | The last Docker Hub tags are removed or repointed. | Sources differ on the clean-up times. Datadog and Wiz put PyPI's quarantine at about 13:20 and image removal at about 13:53; LiteLLM says GHCR was clean by 13:51 but Docker Hub tags pointed at the bad image until 14:20. If you pull from Docker Hub, use the later time. The stolen data was used fast. Wiz saw AWS instance-role credentials taken from victims being tested from two IP addresses between 16:20 and 17:01 UTC. Early coverage blamed stolen GitHub CLI tokens from three engineers: broadly right, but the 23 October record adds the first link (an unrevoked key leaked nearly five months earlier) and rules out an early theory that the tokens came from an internal deployment's logs. Why Python .pth startup hooks make this nastier than a normal malicious package Most malicious packages need importing; this one didn't. The wheel dropped three files into site-packages, outside the litellm folder: - cook.pth, containing one line, import cook. - cook.py, the credential stealer. - sitecustomize.py, a backdoor loader. Python reads every .pth file in site-packages each time it starts, executes any line beginning with import, and imports any module called sitecustomize. Python's documentation says these lines "are always run at Python startup ... regardless of whether a particular module is actually going to be used." They're now marked deprecated, but they still run. In practice: - Any Python process ran the stealer: tests, a Jupyter kernel, a cron job. Microsoft saw a Windows case where installing an unrelated dependency triggered it. - pip uninstall litellm doesn't remove it. The files sit outside the package folder. - Checking with Python can re-trigger it. Running python or pip in a suspect environment starts the hooks again. - The source code looked clean. The official v1.107.0 tag never contained the payload. Endor Labs found one added line in the build settings, data = "cook" in pyproject.toml, copied a cook/ folder that isn't in the public source into the wheel. The line worth remembering: with a .pth hook, installing the package is the same as running it, every time Python starts. What the malware stole Microsoft, Datadog and Wiz agree on what cook.py collected: - Cloud: AWS, Google Cloud and Azure credentials, including instance-role credentials from metadata services; Kubernetes, Docker, Vault and Terraform secrets. - Developer secrets: SSH, GPG and TLS keys; Git, npm and PyPI tokens; .env files; shell history; process environment variables. - AI tools: provider API keys and sessions for AI coding assistants, IDEs and MCP configs. - Browsers: saved passwords and cookies. - LiteLLM's own database: up to 500 rows per query from LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable, where the upstream provider keys live. The haul was encrypted and posted to www.litellm.dev/healthcheck, an attacker-controlled lookalike domain. Separately, sitecustomize.py started a background process on Unix-like systems that checked a GitHub file hourly for a server address, then would download and run code from it. That file only ever held test values, so no second stage was seen. Why AI gateways are prime targets An AI gateway exists to hold every provider key so your apps don't have to. One compromised LiteLLM proxy can hand over your OpenAI, Anthropic, Google and Bedrock keys at once, plus its database password and cloud role. In August 2026, Microsoft told defenders to "treat AI gateways as Tier-0 secrets stores", the most sensitive tier, and this payload carried code written specifically to read LiteLLM's credential tables. Prompt injection attacks what an AI system says; this attacked the keys behind it (our prompt injection attack examples cover the other side). How to check if LiteLLM 1.107.0 is on your systems, step by step Microsoft advises inspecting a suspect environment from a clean machine, not by running Python inside it. Even LiteLLM's suggested pip show litellm restarts the hooks, so use it only where you're rebuilding anyway. 1. Search lock files. Look for litellm 1.107.0 in uv.lock, poetry.lock, Pipfile.lock and requirements*.txt in every repository. 2. Look for the files. Search every site-packages for cook.pth, cook.py, sitecustomize.py, __pycache__/cook*.pyc and a litellm-1.107.0.dist-info folder. On Linux or macOS, find / -name cook.pth 2>/dev/null is a start. 3. Check temp files and processes. A Python daemon holding /tmp/remote_code_loader.lock means the backdoor is running. The marker /tmp/.pc_once_72129118c59f5636c0bb means data was sent; its absence proves nothing. 4. Check images by digest. Run docker image ls --digests and look for sha256:1bbcb1443bc962e45f5aad53fe3d2c30600e0379715fb6c664cdd3ac126c339e. LiteLLM's post lists separate digests for the litellm-database and litellm-non_root images. 5. Search CI logs and caches. Any job between 11:39 and 14:20 UTC on 21 October that installed LiteLLM unpinned or pulled a floating tag is suspect. Check pip and uv caches and internal mirrors too, because a cached copy can be installed later. 6. Check network logs for connections to www.litellm.dev or the header X-Token: XWiA8gZNa7NcX2XRoAEb. For the March incident, look for versions 1.82.7 and 1.82.8 and a file called litellm_init.pth. What to do if you find it: the response checklist LiteLLM's rule: if 1.107.0 ran on a host with outbound internet access, assume the theft succeeded. 1. Isolate affected laptops, build agents, containers and gateway hosts, and stop automation restarting Python on them. 2. Kill and delete. Stop the daemon holding the lock file and delete cook.pth, cook.py, sitecustomize.py and their .pyc files. Don't run pip install first: it re-runs the hooks. 3. Rebuild, don't repair. Rebuild from scratch, pinned to litellm==1.106.2 or one of the signed releases LiteLLM has shipped since 24 October (such as 1.106.4). 4. Rotate from a clean machine everything the malware could reach (see "What the malware stole" above, and the list below). 5. Hunt for misuse. In AWS CloudTrail, look for GetCallerIdentity and Bedrock listing calls from your roles around install time, and any use of those credentials from outside your network. Check provider dashboards for unexpected spend. Pay special attention to: - Cloud: the instance or task IAM role, not just access keys. - LLM keys: every provider key in the proxy database, plus the master key and all virtual keys. - Database and GitHub: the DATABASE_URL password and gh CLI logins. - Sessions: revoke browser and AI tool sessions too; Microsoft notes that rotating a key doesn't always end live sessions. It's a long list, but it's finite, and every step closes a specific door. How to stop the next one After March, LiteLLM had moved to trusted publishing, immutable Docker tags and signed images. The attacker didn't break those controls; they drove the real pipeline with real credentials, and the build provenance honestly recorded Berri-AI/litellm as the source. The answer is layers, not one control. | Control | What it does | Would it have helped here? | | --- | --- | --- | | Lock files with hashes | Installs exactly the files you tested (uv.lock, poetry.lock, or pip install --require-hashes) | Yes, if you were pinned to 1.106.2 | | Delayed adoption | Ignores releases younger than a set age: uv's exclude-newer = "7 days", pip's --uploaded-prior-to P7D | Yes: both poisoned LiteLLM releases were pulled within hours | | Image digests, not tags | Runs image@sha256:... instead of latest | Yes: floating tags were repointed at the bad image | | Check provenance, not just signatures | Confirms the source repository named in the attestation is the real one | Yes: the provenance named the lookalike fork | | Scoped, short-lived tokens | Uses fine-grained tokens with expiry and OIDC instead of personal CLI logins | Partly: the chain started with a personal owner token and an unrevoked key | | Egress allowlists | Lets gateway and build hosts reach only the APIs they need | Yes: posts to www.litellm.dev would have failed | | Two-person release approval | Requires a second human for each release | Partly: that week LiteLLM had relaxed this rule and temporarily widened release permissions | Three habits matter most: - Delayed adoption is the cheapest win. Both LiteLLM compromises this year were caught the same day, so a seven-day wait would have skipped both. Keep a fast path for real security fixes. - Revoke leaked keys; don't just delete the commit. An overwritten commit doesn't make a key useless; only revoking does. - Fence off the gateway. Give each app its own virtual key with a spend limit, and run the gateway as a non-root container with no other credentials on the host, as in our Docker containerisation work. For CI, our guide to GitHub Actions and AWS OIDC replaces stored keys with short-lived ones. The npm ecosystem learned the same lessons in 2025; our npm supply chain attacks article covers the JavaScript side. The March 2026 incident: same project, different route On 24 March 2026, versions 1.82.7 and 1.82.8 appeared on PyPI from 10:39 UTC and were live for about 40 minutes before quarantine, LiteLLM says. It treats anyone who installed between 10:39 and 16:00 UTC as possibly affected. That attacker skipped the release pipeline: LiteLLM believes the compromise came through Trivy, a security scanner in its CI, and the packages were uploaded straight to PyPI. Version 1.82.8 used the same trick, a litellm_init.pth startup hook. Datadog links March to a campaign called TeamPCP and says no connection to October has been established. Frequently asked questions Is LiteLLM safe to use now? As of 26 October 2026, the malicious 1.107.0 has been removed, 1.106.2 is the last version published before the incident, and LiteLLM resumed releases on 24 October (1.106.4 is the latest) with a new image-signing key. LiteLLM says it is revoking every internal token and has removed GitHub permissions from its release workflow. The problem was the release process, not the code, so pin exact versions by hash and wait a few days before upgrading, whichever gateway you use. What is a .pth file in Python, and why is it dangerous? A .pth file in site-packages normally just adds folders to Python's import path, but any line in it starting with import runs every time Python starts, whether or not your code uses that package. That turns one tiny file into code that runs in every Python process, and it survives pip uninstall. Was the LiteLLM Docker image affected? In October, yes. The images on Docker Hub and GHCR tagged v1.107.0, latest, main-latest and main-stable pointed at a poisoned image from 11:46 UTC until between about 13:51 and 14:20 UTC on 21 October 2026, so anyone running a floating tag could have pulled it without choosing 1.107.0. In the March 2026 incident, the official Docker image was not affected because it pinned its dependencies. The bottom line The way into the LiteLLM supply chain attack wasn't clever: a leaked key nobody revoked, a few personal tokens, and a build tool told to copy one extra folder. The defences are just as ordinary: pin by hash, wait before upgrading, run images by digest, scope your tokens and limit where your gateway can talk to. Treat the box that holds every AI key like the box that holds every password, because it is one. Start today with step 1 of the check above: search your lock files for 1.107.0. Running an AI gateway or a Python build pipeline and not sure how exposed it is? Book a free consultation and we'll go through your dependency pinning, secrets and egress rules with you. Sources - LiteLLM, Contained: Oct 21, 2026 LiteLLM release v1.107.0 Compromised (21 October 2026, updated 23 October 2026): docs.litellm.ai - LiteLLM, The attack chain behind the v1.107.0 LiteLLM release (23 October 2026): docs.litellm.ai - LiteLLM, Security Update: Suspected Supply Chain Incident (24 March 2026, updated 27 March 2026): docs.litellm.ai - LiteLLM, Announcing CI/CD v2 for LiteLLM (30 March 2026): docs.litellm.ai - GitHub, BerriAI/litellm release v1.106.4 (25 October 2026): github.com/BerriAI/litellm/releases/tag/v1.106.4 - GitHub, BerriAI/litellm issue #47826, litellm 1.107.0 compromised (21 October 2026): litellm#47826 - Microsoft Security Blog, Inside the LiteLLM 1.107.0 supply chain compromise: Credential theft through Python startup hooks (23 October 2026): microsoft.com - Microsoft Security Blog, When AI infrastructure becomes the target: Securing gateways and control points (26 August 2026): microsoft.com - Datadog Security Labs, LiteLLM supply chain compromise: What you need to know (21 October 2026): securitylabs.datadoghq.com - Wiz, LiteLLM Compromised, Steals Credentials (21 October 2026, updated 22 October 2026): wiz.io - Endor Labs, LiteLLM 1.107.0 Compromised: Python Startup Hooks Steal Cloud, AI, and Proxy Credentials (21 October 2026): endorlabs.com - Python documentation, site: Site-specific configuration hook: docs.python.org - pip documentation, pip install (--uploaded-prior-to, --require-hashes): pip.pypa.io - uv documentation, Settings reference (exclude-newer): docs.astral.sh --- Originally published on znovastudio.com.
【一次資料・GitHub】 ■ 実際の不具合報告と再現結果 litellm#48543 ■ 私の独自指摘:料金を決める対象と課金すべき操作の区別 litellm#48543 ■ 開発者による修正・実接続検証・マージ litellm#49392 ■ メンテナによる承認 litellm#49392
LiteLLM 10月28日的补丁修了一个卡请求的细节:原先部分响应在事件循环里做 gzip 压缩,现在只压缩仪表盘统计,交给工作线程处理。 代价也明确:模型推理响应不再压缩,走公网部署时得重新看流量。 BerriAI/litellm
LambdaとBedrockで自作するLLMプロキシ(キー発行・予算・管理UI) 社内でClaude CodeやCodex CLIを使う人が増えてくると、「誰がどのモデルにいくら使っているかわからない」「キーを配るしくみがない」「危ないツールを禁止したい」という話になります。 LiteLLMを立てれば解決しますが、常時起動のコンテナとDBを運用するほどの規模ではなく、依存パッケージの脆弱性を追いかけ続けるのも避けたいと考えました。 そこでAWS LambdaとAmazon Bedrockで、必要な機能だけを持つLLMプロキシを自前で作ってみました。 できあがったものは、Anthropic形式とOpenAI形式の両方を受け付けるプロキシです。Claude以外のBedrockモデルも同じキーで使え、キーやチームごとに使えるモデルやツール、予算などを絞り、1リクエスト1行のログを残します。 キーの発行や利用量の確認は管理UIとCLIからでき、予算の消化が進むと通知が飛びます。固定費はかからず、コードは依存パッケージなしの6ファイル、インフラはTerraformです。 コードはGitHubで公開しています。 upgradeinc-c-m/simple-llm-proxy 構成を決めるまで LiteLLMを使わなかった理由 最初の候補はLiteLLMでした。Virtual Keyの発行、キーごとの予算、Admin UIと、欲しいものは一通りそろっています。 ただし本格的に使うにはECSかEC2で常時起動させ、キー管理のためにPostgreSQLも必要です。使わない時間帯にも課金が続き、LiteLLM本体と依存パッケージのアップデートも追い続けることになります。 今回の要件は「ログと最低限の制限、キーの配布と予算」で、ルーティングやフォールバックのような高度な機能は不要でした。 OpenAI形式とAnthropic形式の変換の対応関係は、LiteLLMの実装を参考にしています。 Lambda Function URLにした理由 サーバレスで受け口を作る選択肢を並べました。 構成 固定費 ストリーミング 所感 ECS/Fargate + LiteLLM 常時起動分 可 機能は豊富だが今回の要件には重い API Gateway + Lambda なし REST APIは可、HTTP APIは非対応 認証をプロキシ側で持つならAPI Gatewayの出番が少ない Lambda Function URL なし 可(RESPONSE_STREAM) 今回はこれ 決め手はストリーミングです。Claude CodeもCodexもストリーミング前提のため、Function URLのInvokeMode: RESPONSE_STREAMでBedrockのイベントストリームをそのまま流せるのが効きました。 詳細はレスポンスストリーミングのドキュメントにあります。 認証はプロキシの中で独自のAPIキーを見るので、Function URLのAuthTypeはNONEにしています。 公開エンドポイントを守っているのはAPIキーの照合と送信元CIDRだけのため、WAFが必要ならCloudFrontを前へ置き、Function URLをAWS_IAMへ変える構成になります(今回は試していません)。 BedrockにはAnthropic形式をそのまま渡せる 転送先をBedrockにしたのは、Anthropic Messages APIのボディをほぼそのまま受けてくれるからです(Bedrockのドキュメント)。 modelを外してanthropic_versionを足すだけのため、プロキシは認証・制限・ログだけを担当し、本文は透過できます。先に直接投げて確かめました。 aws bedrock-runtime invoke-model \ --model-id jp.anthropic.claude-haiku-4-5-20251001-v1:0 \ --body '{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":"1+1は?数字だけ答えて"}]}' \ --cli-binary-format raw-in-base64-out /dev/stdout {"model":"anthropic.claude-haiku-4-5-20251001-v1:0","id":"msg_bdrk_...","type":"message","role":"assistant","content":[{"type":"text","text":"2"}],...,"stop_reason":"end_turn",...,"usage":{"input_tokens":21,...,"output_tokens":5,...}} OpenAI形式(Chat Completions、Responses)はLambdaの中でAnthropic形式に変換してから、同じ経路に流します。 モデルは国内で処理したかったのでjp.の推論プロファイルを使っています。 Claude以外のモデル(NovaやLlama)も使いたかったので、モデルIDにanthropic.を含まないものはBedrockのConverse APIに変換して呼ぶようにしました。 Converseはモデル非依存のAPIで、入力も出力もAnthropic形式に機械的に対応づけられます。入口の形式は3つ、上流は2つですが、間はすべてAnthropic形式で統一しているので、組み合わせが増えても変換は増えません。 キーと利用量はDynamoDBに置く 最初はキー設定をSSMパラメータのJSONに手書きしていました。試すには十分でしたが、人に配るには「発行して渡す」「使いすぎたら停止する」が欠かせません。 そこでDynamoDBに1テーブルを用意し、キーの設定(SHA-256ハッシュで引く)、期間ごとの利用量、モデル単価を置きました。平文のキーは発行時に1回表示するだけで、保存しません。 あとからチーム(所属キー全体の予算とレート)、ロール付きの管理トークン、管理操作の監査ログも同じテーブルに足しています。項目の種類は増えましたが、テーブルは1つのままです。 予算の判定に使う費用はこの単価テーブルから計算します。Bedrockの請求とは丸めや単価改定でずれうるので、請求はCost Explorerで見る前提です。 管理操作はプロキシと同じエンドポイントには置かず、IAM権限で動くCLI(keyctl.py)と、別のFunction URLで動く管理UIに分けました。プロキシ本体のLambdaはキーの作成と削除の権限を持ちません。 環境 リージョンはap-northeast-1、Lambdaランタイムはnodejs22.x。 aws-cli 2.38.5、Terraform 1.17.1(AWSプロバイダ6.71.0)、Node.js 22.23.3。 Claude Code 2.1.309、@anthropic-ai/sdk 1.3.0、openai 7.45.1、Codex CLI 0.165.0。 検証日は2026-10-29〜31。 やってみた Terraformでデプロイする リソースはDynamoDBテーブル、ロググループ、IAMロール、Lambda関数、Function URL、ダッシュボード、予算通知用のSNSトピックと、管理UI用のLambda一式です。 コードはarchive_fileでzipにするだけで、ビルドもパッケージ用のS3バケットも不要です。 cd terraform && terraform init && terraform apply Outputs: function_url = "xxxxxxxx.lambda-url.ap-northeast-1.on.aws" admin_url = "yyyyyyyy.lambda-url.ap-northeast-1.on.aws" dashboard_url = "ap-northeast-1.console.aws.amazon.com" keys_table_name = "llm-proxy-keys" log_group_name = "/aws/lambda/llm-proxy" alert_topic_arn = "arn:aws:sns:ap-northeast-1:<account>:llm-proxy-alerts" Function URLをauthorization_type = "NONE"で作ると、AWSプロバイダがlambda:InvokeFunctionUrlとlambda:InvokeFunctionの2つの許可を自動で付けてくれます。 CloudFormationで組んでいたときはこの2つ目を書き忘れて全リクエストが403になりました(後述)。CloudFormationならInvokedViaFunctionUrl: trueのAWS::Lambda::Permissionを追加します。 Claude Codeから使う場合は、Bedrockが受け付けるanthropic-betaだけを許可リストに入れておきます。 terraform apply -var 'allowed_betas=context-management-2025-06-27,interleaved-thinking-2025-05-14' モデル単価を登録してAPIキーを発行する 予算の判定に使う単価を先に入れます。使うモデルの単価を料金ページから転記します。 python3 keyctl.py set-prices 'jp.anthropic.claude-haiku-4-5-20251001-v1:0=1.1:5.5,jp.anthropic.claude-sonnet-4-5-20250929-v1:0=3.3:16.5' キーはCLIか管理UIで発行します。CLIだとこうなります。平文はこのとき1回しか表示されません。 python3 keyctl.py create --name team-a \ --models 'claude-haiku-4-5=jp.anthropic.claude-haiku-4-5-20251001-v1:0,claude-sonnet-4-5=jp.anthropic.claude-sonnet-4-5-20250929-v1:0' \ --deny-tools Bash,exec_command --cidrs 203.0.113.0/24 \ --budget-usd 50 --budget-period monthly --rpm 60 --tpm 200000 --expires-in-days 90 created key_xxxxxxxxxxxx (team-a) API key (shown once, store it securely): llmp_................................................ --modelsはクライアントが送る短い名前とBedrockモデルIDの対応表で、そのまま許可リストにもなります。モデルを差し替えるときにクライアント側の設定を触らずに済みます。 --deny-toolsはリクエストに含まれるツール定義の名前で判定します。大文字小文字を区別するので、Claude CodeならBash、Codexならexec_commandです。 発行後はlistで一覧、usageで期間別の利用量、updateで制限の変更、disableで一時停止、revokeで失効ができます。 平文の漏えいが疑われるときはregenerateで、keyIdと設定と利用量を保ったまま平文だけ作り直せます。旧平文はキャッシュが切れる60秒以内に使えなくなります。 管理UIから発行する場合は、admin_urlを開いてkeyctl.py admin-tokenで作った管理トークンを貼り付けます。 キーごとの状態、モデル、ツール制限、今月のリクエスト数とトークン数、推定費用、予算の消化率が1行で見える 発行直後に平文が1回だけ出る。閉じると二度と取り出せない Claude Codeからつなぐ 接続先と認証トークンの環境変数を差し替えるだけです。モデル名はプロキシ側で決めたエイリアスを指定します。 export ANTHROPIC_BASE_URL=xxxxxxxx.lambda-url.ap-northeast-1.on.aws export ANTHROPIC_AUTH_TOKEN=llmp_... export ANTHROPIC_MODEL=claude-haiku-4-5 export ANTHROPIC_SMALL_FAST_MODEL=claude-haiku-4-5 claude -p "1+1は?数字だけ答えて" 2 ログのtoolsには、Claude Codeがモデルに渡したツール定義の名前が全部並びます。Agent、Bash、Edit、Read、Write、WebFetchにMCPのツール名まで見えるので、プロキシ側からクライアントの顔ぶれがわかります。 ここでBashをdenyしたキーに切り替えると、Claude Codeは起動時に止まります。 Failed to authenticate. API Error: 403 tools not allowed: Bash Codex CLIからつなぐ CodexはOpenAIのResponses APIを話すので、/v1/responsesで受けてAnthropic形式に変換しています。~/.codex/config.tomlでプロバイダを差し替えます。 ~/.codex/config.toml model_provider = "llmproxy" model = "claude-haiku-4-5" [model_providers.llmproxy] name = "llm-proxy" base_url = "xxxxxxxx.lambda-url.ap-northeast-1.on.aws" env_key = "LLM_PROXY_KEY" wire_api = "responses" export LLM_PROXY_KEY=llmp_... codex exec --dangerously-bypass-approvals-and-sandbox "このディレクトリにあるファイル名を列挙して、ファイル名だけ答えて" codex 現在のディレクトリのファイルを確認します。 exec /bin/bash -lc 'ls -1' in /tmp/codex-test succeeded in 0ms: sample.txt # codex sample.txt exec_commandのfunction_call、実行結果の返却、最終回答という往復がプロキシ越しに通りました。 サンドボックスを外すフラグを付けたのは、検証に使ったコンテナでbubblewrapが動かなかったためです。通常のLinuxやmacOSでデフォルトのまま動くかは試していません。 exec_commandをdenyしたキーだと、CodexはERROR: Reconnecting... 1/5から5/5まで再接続を繰り返します。 そのあとでunexpected status 403 Forbidden: tools not allowed: exec_command, write_stdinと出て終了します。Claude Codeのように即座には出ません。 OpenAI SDKからもbaseURLに<Function URL>/v1を指定すれば、Chat CompletionsとResponsesの両方でツール呼び出しの往復とストリームが通りました。 予算とRPMで制限する 小さい上限を付けたキーで、超過時の応答を確かめました。RPM 3のキーで同じ分に5回投げると、4回目から429です。 req1 14:15:35: content="Hello! " HTTP 200 req2 14:15:43: content="# Hey! " HTTP 200 req3 14:15:43: content="Hey! " HTTP 200 req4 14:15:44: {"error":{"message":"rpm limit exceeded for key (3 requests per minute)","type":"rate_limit_error","param":null,"code":null}} HTTP 429 req5 14:15:44: {"error":{"message":"rpm limit exceeded for key (3 requests per minute)","type":"rate_limit_error","param":null,"code":null}} HTTP 429 日次予算を0.00005ドルにしたキーは、1回目(費用0.000053ドル)で予算を超え、2回目から429になりました。 {"type":"error","error":{"type":"insufficient_quota","message":"budget exceeded for key (spent 0.000053 of 0.00005 USD daily)"}} 有効期限切れと無効化は403、失効したキーは401です。ただしキー設定はLambdaコンテナごとに60秒キャッシュしているので、失効直後の1分間は通ります。 判定はBedrockを呼ぶ前にその時点の累計で行い、利用量の加算は応答が終わってからです。並列にリクエストが来ると上限を多少超えることがありますが、ここは割り切りました。 Claude以外のモデルを同じキーで使う モデルの対応表にNovaを足すだけです。IDにanthropic.が含まれないので、プロキシはConverse APIで呼びます。クライアント側はmodelを変えるだけで、Anthropic形式とChat Completionsのどちらからも同じように動きました。 python3 keyctl.py update key_xxxxxxxxxxxx --models 'claude-haiku-4-5=jp.anthropic.claude-haiku-4-5-20251001-v1:0,nova-lite=apac.amazon.nova-lite-v1:0,titan-embed=amazon.titan-embed-text-v2:0' curl "$URL/v1/chat/completions" -H "authorization: Bearer $KEY" -d '{"model":"nova-lite","messages":[{"role":"user","content":"東京の天気を教えて"}],"tools":[...]}' {"choices":[{"message":{"role":"assistant","content":"<thinking> ... </thinking>\n","tool_calls":[{"id":"tooluse_...","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"東京\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":398,"completion_tokens":59,...}} Novaはツール呼び出しの前に<thinking>を本文に書く癖がありますが、それはモデルの挙動なので手を入れていません。モデルを足すたびにプロキシを触らずに済むのは、途中をAnthropic形式で統一しておいた効果でした。ストリーミングもResponses APIも同じ経路で通りました。 埋め込みは/v1/embeddingsで受け、TitanかCohereのモデルに流します。Titan v2で2件入力すると1,024次元のベクトルが2本返りました。 モデルの対応表はalias=modelA|modelBと書くとフォールバックになります。1本目がスロットリングや5xx、またはモデルIDの無効化(廃止)で失敗したら2本目を試し、ログにfallbackToを残します。 存在しないIDを1本目に置いて試すと、2本目のNovaで200が返りました。 チームで予算をまとめる 個人のキーに加えて、チーム単位の上限が欲しくなります。チームを作って予算とRPMを持たせ、キーを所属させると、キー自身の制限とチーム合計の両方で判定します。 python3 keyctl.py team create --name platform --budget-usd 200 --budget-period monthly --rpm 300 python3 keyctl.py create --name alice --team team_xxxxxxxxxxxx --models '...' python3 keyctl.py team usage team_xxxxxxxxxxxx RPMはDynamoDBの条件付き書き込みで数えています。分ごとの項目にreservedを加算するとき、reserved < rpmを条件にして、失敗したら429です。 チームRPMを2にして、2本のキーから同時に6リクエスト投げると、200が2件、429が4件で、並列でも上限を超えませんでした。 k2 200 k2 429 k1 429 k2 429 k1 429 k1 200 {"type":"error","error":{"type":"rate_limit_error","message":"rpm limit exceeded for team test-team (2 requests per minute)"}} 予算はその時点までの累計で見るので、ストリーミングの途中で超えた分は次のリクエストから効きます。 チームの一覧と監査ログ。チームの行には所属キー数と今月の合計が出る 予算の消化を通知する 429になって初めて気付くのは不親切なため、予算の80%と100%へ達したときにSNSへ1回だけ通知するようにしました。 利用量を加算したあと、alerted80やalerted100のフラグを条件付き書き込みで立て、立てられたときだけPublishします。並列でも通知は1回です。 日次予算0.00006ドルのキーに3回投げたときの結果です。SNSのメトリクス上の発行数も2件でした。 1回目: 200。消化率88%になり、80%の通知。 2回目: 200。消化率176%になり、100%の通知。 3回目: 429。 {"alert":"[llm-proxy] key \"alert-test\" reached 80% of budget"} {"alert":"[llm-proxy] key \"alert-test\" reached 100% of budget"} メールで受け取るならTerraformのalert_emailを設定します。購読確認のメールが届くので、そこだけ手作業です。 Guardrailsをキーに付ける Bedrock GuardrailsのIDとバージョンをキーの設定に持たせると、Claude(InvokeModel)とNova(Converse)の両方へ同じガードレールが付きます。 python3 keyctl.py update key_xxxxxxxxxxxx --guardrail abcd1234efgh:2 語句フィルタに社外秘を入れたガードレールで試すと、どちらの経路でも本文がブロック文言に置き換わりました。 Nova(Converse)をChat Completions形式で呼ぶとfinish_reasonはcontent_filterになります。Claude(InvokeModel)ではstop_reasonはend_turnのままで、本文だけが置き換わります。 {"id":"bedrock-guardrails-...","content":[{"type":"text","text":"このリクエストはガードレールでブロックされました。"}],"stop_reason":"end_turn",...} 管理トークンにロールを付けて操作を記録する 管理UIのトークンは複数発行でき、viewerロールは参照だけです。POSTやDELETEは403で返ります。 管理APIで何かを変えるたびに、誰が・いつ・何を・どのキーに対して行ったかを監査ログとして同じテーブルに残し、UIとCLIの両方から月ごとに見られます。 python3 keyctl.py admin-token --name auditor --role viewer python3 keyctl.py audit --month 2026-10 2026-10-31T13:55:44.734Z ops POST /api/keys/key_adef5aa8dbc0/regenerate key_adef5aa8dbc0 {} 利用者が自分の利用量を確認したいときは、プロキシのGET /v1/usageに自分のキーで問い合わせます。自分とチームの日・月の利用量、上限、有効期限が返るので、管理者に聞かなくて済みます。 利用状況を見る ログが1リクエスト1行のJSONのため、Logs Insightsのクエリをそのまま並べたダッシュボードをTerraformで作っています。 {"ts":"2026-10-31T01:20:55.032Z","requestId":"...","sourceIp":"203.0.113.10","method":"POST","path":"/v1/chat/completions","format":"chat","keyName":"limits-test","keyId":"key_14a5527775be","model":"claude-haiku-4-5","stream":false,"tools":[],"modelId":"jp.anthropic.claude-haiku-4-5-20251001-v1:0","costUsd":3.1e-05,"decision":"allow","inputTokens":8,"outputTokens":4,"stopReason":"max_tokens","cacheReadTokens":0,"cacheWriteTokens":0,"status":200,"latencyMs":661} キー別の件数とトークン数、拒否理由、推移、レイテンシ、キー別の費用、制限による拒否、渡されたツール名。制限のテストで出した429や403がそのまま並ぶ コンソールを開かずに共有したいときはusage-report.pyで同じ集計をMarkdownにします。AWS CLIの認証情報だけで動きます。 python3 usage-report.py --hours 24 --region ap-northeast-1 実装のポイント コードはプロキシ本体が4ファイルです。index.mjsはルーティングと認証と転送、keys.mjsはキーとチームの照合・制限判定・費用計上・アラートを担当します。 openai.mjsはOpenAI形式の変換、converse.mjsはConverse APIとの変換です。管理UIはadmin.mjsとadmin-ui.mjsの2ファイルです。 AWS SDK v3はLambdaのNode.jsランタイムに同梱されているので、依存パッケージはありません。全体はリポジトリを見てもらうとして、要点だけ書きます。 モデル・ツール・CIDRの判定はこれだけです。拒否理由をそのままクライアントへ返し、ログにも残します。 src/index.mjs(抜粋) function authorize(keyConfig, sourceIp, alias, toolNames) { if (keyConfig.cidrs?.length && !keyConfig.cidrs.some((c) => ipInCidr(sourceIp, c))) { return { status: 403, type: "permission_error", message: source ip ${sourceIp} is not allowed }; } if (!keyConfig.models?.[alias]) { return { status: 403, type: "permission_error", message: model "${alias}" is not allowed. allowed: ${Object.keys(keyConfig.models ?? {}).join(", ")}, }; } const { allow, deny } = keyConfig.tools ?? {}; const blocked = toolNames.filter((n) => (allow && !allow.includes(n)) || (deny && deny.includes(n))); if (blocked.length) { return { status: 403, type: "permission_error", message: tools not allowed: ${blocked.join(", ")} }; } return null; } DynamoDBのテーブルの主な項目は次のとおりです。種類は増えましたが、どれもpkの接頭辞で見分けられます。 pk sk 内容 KEY#<sha256(key)> META 名前、所属チーム、モデル、ツール、CIDR、予算・RPM・TPM、Guardrail、有効期限、無効化 KEY#<sha256(key)> / TEAM#<teamId> USAGE#min#… / day#… / month#… リクエスト数、RPM用の予約数、トークン数、費用(マイクロドル)、通知済みフラグ。分の項目はTTLで消える TEAM#<teamId> META チーム名と予算・RPM・TPM ADMIN#<sha256(token)> META 管理トークンの名前とロール AUDIT#<yyyy-mm> <時刻>#<乱数> 管理操作の記録(操作者、操作、対象) CONFIG PRICES モデルIDごとの単価(100万トークンあたりUSD) 利用量制限はこのテーブルから当該分と当該期間の項目を読んで比べます。RPMだけは読んで比べるのではなく、分の項目に予約数を条件付きで加算して、失敗したら429にしています。超過はBedrockを呼ぶ前に返すので、無駄なトークン課金は起きません。 src/keys.mjs(抜粋) // RPM: 分の項目に reserved を条件付きで加算する(並列でも上限を超えない) for (const s of scopes) { if (!s.limits.rpm) continue; try { await ddb.send(new UpdateItemCommand({ TableName: TABLE, Key: marshall({ pk: s.pk, sk: USAGE#${p.minute} }), UpdateExpression: "ADD reserved :one SET #ttl = if_not_exists(#ttl, :ttl)", ConditionExpression: "attribute_not_exists(reserved) OR reserved < :rpm", ExpressionAttributeNames: { "#ttl": "ttl" }, ExpressionAttributeValues: marshall({ ":one": 1, ":rpm": s.limits.rpm, ":ttl": Math.floor(now.getTime() / 1000) + 3600 }), })); } catch (e) { if (e.name === "ConditionalCheckFailedException") { return { status: 429, type: "rate_limit_error", message: rpm limit exceeded for ${s.label} (${s.limits.rpm} requests per minute) }; } throw e; } } Converseへの変換で気を付けたのは、ストリームの形です。ConverseStreamはテキストブロックの開始イベントを送らないので、最初のdeltaが来たときにcontent_block_startを補ってからAnthropicのイベント列に戻します。これで後段のChat CompletionsやResponsesへの変換には手を入れずに済みました。 ストリーミングは、Bedrockから届くチャンクをJSONとして読み、Anthropic形式の入口ならevent: <type>\ndata: <json>\n\nに組み直すだけです。 OpenAI形式の入口では、同じイベントをchat.completion.chunkやResponses APIのresponse.output_text.deltaなどに変換して流します。 awslambda.streamifyResponseとawslambda.HttpResponseStream.fromはランタイムが提供するグローバルで、importは不要です。 管理UIは別のLambdaで、HTML(約23KB)をJavaScriptのテンプレート文字列として同梱し、同じ関数が/api/*でJSONを返します。フレームワークもビルドもCDNも使っていません。 管理トークンはDynamoDBにハッシュだけを置いて定数時間で比較し、Terraformのadmin_cidrsで送信元を絞れます。SSOで守りたい場合はCognitoなどを前へ置く構成に差し替えます。 1リクエストの流れをまとめると次の図になります。 Claudeを呼ぶときの流れ。Claude以外ではInvokeModelの部分がConverseに変わる 対応しているルートは、会話用の/v1/messages、/v1/chat/completions、/v1/responsesと、埋め込みの/v1/embeddingsです。 補助として/v1/messages/count_tokens、/v1/models、/v1/usage、/healthがあります。 未対応なのは、画像生成や音声、バッチのエンドポイントと、OpenAI側のprevious_response_id、response_formatのtext以外、n > 1、ホスト型ツールです。 キーの一覧とkeyIdからの検索はテーブルのScanで済ませているので、キーが数千件を超えるならGSIを足すことになります。 ハマったところ Function URLが全リクエストで403を返す 最初はCloudFormationで組んでいました。デプロイ直後は、匿名リクエストもIAM署名付きリクエストも次の応答しか返しません。 {"Message":"Forbidden. For troubleshooting Function URL authorization issues, see: docs.aws.amazon.com"} aws lambda invokeで直接呼ぶと関数は正常で、CloudWatchにログストリームもありません。URLの手前で止まっています。 リソースポリシーにはlambda:InvokeFunctionUrlを付けてあり、ドキュメントどおりに見えました。 原因は、同じアカウントでコンソールから作った別のFunction URLのポリシーと見比べてわかりました。そちらにはlambda:InvokeFunction(条件lambda:InvokedViaFunctionUrl: true)という2つ目のステートメントがあります。 CloudTrailにはInvokeFunctionUrlの失敗が記録されない(データイベントはデフォルトで記録されない)ので、手元に「動いているFunction URL」がないと気付きにくいです。Terraformのaws_lambda_function_urlはこの2つを自動で付けてくれます。 anthropic-betaヘッダをそのまま渡すとBedrockが400を返す 最初はクライアントのanthropic-betaヘッダをBedrockのanthropic_betaにそのまま渡していました。curlとSDKでは問題なかったのですが、Claude Codeの初回リクエストでBedrockが400を返します。 ValidationException: Unexpected value(s) advanced-tool-use-2025-11-20 for the anthropic-beta header. Claude Codeはinterleaved-thinking-2025-05-14やadvanced-tool-use-2025-11-20など5つのbetaを送ってきて、Bedrockの知らない値が1つでも混ざると丸ごと400です。 環境変数の許可リストと一致する値だけ渡し、落とした値はdroppedBetasとしてログへ残すようにしました。許可リストを空にすると今度はcontext_management: Extra inputs are not permittedになります。betaを落としてもボディ側のcontext_managementは残るためです。 Claude Codeは400を受けると該当機能を外して自動で再試行するので動作はしますが、毎回エラーログが1行残ります。 context-management-2025-06-27とinterleaved-thinking-2025-05-14を許可すると初回から200で通りました。どのbetaをBedrockが受けるかはモデルと時期で変わるので、droppedBetasを見ながら増やす運用にしています。 OpenAI経路でanthropic_versionを付け忘れた OpenAI互換を足した最初のデプロイで、Chat CompletionsもResponsesもすべてValidationException: anthropic_version: Field required(400)でした。Anthropic形式の入口だけがこのフィールドを付けていたからです。 Bedrockへ送る直前で共通して付けるよう直しましたが、この400を受けたCodexはリトライを続け、180秒のタイムアウトまで終わりません。原因に気付くまで少し時間がかかりました。 Codexが送るnamespaceとweb_search Codexをつないだ直後、プロキシがunsupported tool types: namespace, web_searchを返しました。ローカルのダミーサーバに向けてリクエストを捕まえると、ツール定義にfunction以外の2種類が混ざっています。 namespaceは複数のfunctionをまとめた入れ物のため、中身を平坦に展開します。web_searchはOpenAIのホスト型ツールで提供できないので、落としてログへ残すようにしました。 Codex側のweb_search = "disabled"も試しましたが、0.165.0ではツール定義は送られてきたままでした。 上流のエラー文にIAMロールのARNが入っていた count_tokensの検証で403が返ったとき、クライアント側にBedrockのエラー文がそのまま見えました。 文面はUser: arn:aws:sts::<アカウントID>:assumed-role/... is not authorized to perform: bedrock:CountTokensです。実行ロールのARNとアカウントIDが含まれています。 原因はBedrockのエラーメッセージを転送していたことでした。403と5xxは詳細をログにだけ残し、クライアントには「upstream access denied (check the IAM policy)」のような一般的な文言を返すように変えました。 count_tokensは推論プロファイルIDを受け付けない /v1/messages/count_tokensをBedrockのCountTokensに流したところ、まずIAMにbedrock:CountTokensがなくて403でした。 次にjp.付きの推論プロファイルIDでは「The provided model doesn't support counting tokens」と返ります。 接頭辞を外した基盤モデルIDで呼ぶと通りましたが、今度はmax_tokens: Field requiredです。数えるだけなのに必須のため、省略時は1を補っています。 Guardrailsのトピック拒否が効かない 日本語で「特定の株式や暗号資産の売買を勧める投資助言」を拒否トピックにしたガードレールは、ApplyGuardrailで直接試してもaction: NONEでした。モデル自身が投資助言を断るので、一見ブロックされたように見えて紛らわしいところです。 プロキシ経由の配線を確かめたかったので、確実に効く語句フィルタに切り替えて検証しました。トピック拒否の精度は定義文と例文の書き方で変わるため、本番で使うなら先にApplyGuardrail単体で当たり方を確かめたほうがよさそうです。 キー一覧にチームと管理トークンが混ざる キー一覧はsk = METAでScanしていたので、同じテーブルに足したチームと管理トークンの項目まで一覧に出てきました。begins_with(pk, "KEY#")を条件に足して直しています。1テーブルに種類の違う項目を同居させるときの定番の落とし穴でした。 Function URLの末尾スラッシュ 管理UIの/api/*が全部404でした。Terraformのfunction_url出力は末尾に/が付くので、/api/keysをつなぐと//api/keysになります。 ハンドラは末尾のスラッシュしか正規化しておらず、startsWith("/api/")に引っかかりませんでした。連続するスラッシュもまとめるようにして解決です。 ダッシュボードの遅延描画 ヘッドレスブラウザでダッシュボードを確認していて、最下段のウィジェットだけ「No data found」のままでした。クエリの不具合だと思って書き直しましたが、原因は画面外のウィジェットがスクロールするまで描画されないことでした。コンソールで確認するときは最下段までスクロールしてから判断します。 料金 プロキシ自体の費用だけを見積もります。Bedrockのトークン課金は使うモデルと使い方で決まるので、ここでは扱いません。 単価は2026-10-30時点のPrice List APIから取り、計算はリポジトリのcost-estimate.pyでしました。 項目 単価(ap-northeast-1) Lambdaリクエスト / 実行時間 $0.20 / 100万、$0.0000166667 / GB秒(月100万リクエストと40万GB秒まで無料) CloudWatch Logs 取り込み$0.76 / GB、保存$0.033 / GB月 DynamoDB(オンデマンド) 書き込み$0.715 / 100万、読み込み$0.1425 / 100万、保存25GBまで無料 1リクエストあたりLambda 3秒×256MB、ログ700バイト、DynamoDB読み込み1単位・書き込み3単位と置いた試算です。 月間リクエスト Lambda CloudWatch Logs DynamoDB 合計 1万 $0.00 $0.01 $0.02 $0.03 10万 $0.00 $0.05 $0.23 $0.28 100万 $5.83 $0.52 $2.29 $8.64 月10万リクエストでも30セント弱で、Lambdaの無料枠に収まります。なお2025年7月以降に作成したアカウントは無料利用枠がクレジット制のため、常時無料の枠が同じ形で使えるかはアカウントの種類で変わります。 CloudWatchダッシュボードはアカウントで3枚まで無料、4枚目以降は1枚$3/月で、create_dashboard = falseで外せます。管理UIのLambdaは管理者が開いたときだけ動くので、こちらも無料枠内です。 SNSはAPIリクエストが月100万件、メール通知が月1,000件まで無料枠があり、予算通知の費用は0円とみなせます。 Guardrailsを付ける場合はポリシーの種類ごとに別途課金があります。Price List APIの東京の単価では、語句フィルタは無料、トピック拒否とコンテンツフィルタは1,000テキストユニットあたり$0.15でした。 まとめ Lambda Function URLとBedrockにDynamoDBを1テーブル足すだけで、キーの発行、予算、通知、管理UIまでそろいました。Claude以外のモデルもConverse APIへの変換で同じキーから使えます。 LiteLLMにあってこちらにないものは、応答キャッシュ、ルーティングの重み付け、管理UIのSSOです。キャッシュとSSOはRedisやCognitoのような常時動く基盤が前提になるので、固定費ゼロの範囲では持たないと決めました。重み付けは、フォールバックで足りる規模だったので見送っています。 作り込まれた機能は当然ありませんが、固定費ゼロで、追いかける依存もない状態を維持できるのは小さなチームには合っていると感じました。 予算の判定は事後計上で厳密ではないので目安として使い、請求はCost Explorerで確認するのがよさそうです。どなたかの参考になれば幸いです。
【一次資料・GitHub】 ■ 利用額の誤記録と予算管理への影響 litellm#48937 ■ 私が指摘した「0円設定」の盲点 litellm#48953 ■ 開発者による対応の明言 litellm#48953 ■ 実際のコード変更・追加テスト・マージ litellm#48953
The LiteLLM MCP auth bypass, and the one request that shows it Originally published on mcpscore.dev. On 2 September 2026, CISA added CVE-2026-59822 to its Known Exploited Vulnerabilities catalog. LiteLLM’s MCP endpoint let anyone in who sent a bearer token, and any token would do. The flaw is in LiteLLM’s code, but the symptom is one any MCP server can have, and one request from outside shows it. mcpscore 1.25.0 now sends that request. What went wrong in LiteLLM LiteLLM is a gateway: it sits between applications and model providers, and between agents and MCP servers. Its MCP endpoint checks the Authorization header against LiteLLM’s own API keys. Some upstream MCP servers, Atlassian among them, expect their own OAuth token in that same header, so LiteLLM added a fallback: if the header is not a LiteLLM key, treat it as a token for the upstream server and pass it through. The fallback ran for every server, not only the ones configured for OAuth. In outline, per the advisory: # The shape of the bug, simplified from the advisory try: identity = await check_litellm_key(authorization) except AuthError: # Maybe it is an OAuth token for an upstream server. identity = AnonymousUser() # every server, not only OAuth ones A failed key check turned into an anonymous session, so a made-up header could list and call every tool behind the gateway and reach the services connected through it. The fix in 1.84.0 runs the fallback only when every server the request targets is configured for OAuth, and fails closed when the target is unknown. Why patching to 1.83.9 is not enough Two more LiteLLM CVEs made the security news in October, and the widely shared advice was to upgrade to at least 1.83.9. That advice is right for those two and leaves the MCP bypass open: CVE-2026-59822 affects every release before 1.84.0, so 1.83.9 through 1.83.14 are still exposed. Recent LiteLLM CVEs, the release that fixes each, and whether it involves MCP | CVE | Flaw | Fixed in | CISA KEV | MCP | | --- | --- | --- | --- | --- | | CVE-2026-59822 | MCP endpoint accepts any bearer token | 1.84.0 | Yes | Yes | | CVE-2026-59823 | Server-side request forgery via user_config | 1.83.9 | No | No | | CVE-2026-35029 | Privilege escalation via the config endpoint | 1.83.0 | No | No | The two newer CVEs are not about MCP, and no outside audit can see them. The third is the one CISA has evidence of attackers using, and its symptom is visible from outside. What the spec requires The MCP authorization spec has said the same thing since 2025-06-18, in Token Handling, and the 2026-07-28 release keeps it word for word: > Invalid or expired tokens MUST receive a HTTP 401 response. A server that serves a request carrying a token it never issued has not just answered with the wrong status code. Its auth check did not run, or ran and was ignored. Either way, the cause is inside the server, but the result shows on the wire. The check: one made-up token The new rule, auth_invalid_token_rejected, runs on any HTTP server that refuses a request without a token. mcpscore sends the same request again, a tools/list, with Authorization: Bearer mcpscore-invalid-token- followed by a random UUID. No real server issued that token, so the only correct answer is a refusal. - Fail: any 2xx. The server served the request, and the rule is HIGH, worth three points. - Pass: 401, 403, or another 4xx whose WWW-Authenticate header carries error="invalid_token" (RFC 6750 §3.1). A refusal other than 401 passes with a note, since it misses the MUST without letting anyone in. - Skip: anything else, such as a 404 to both requests, a redirect, a rate limit or a 5xx. The second pass condition is there because of a real server. GitHub Copilot’s MCP server answers a malformed token with HTTP 400 and error="invalid_token", and a token shaped like a real GitHub token with 401. Both are refusals, and failing the first at HIGH would have been a false positive. The rule only reads. It sends no tools/call, follows no redirect with the token, and if the server opens a session anyway, closes it with a DELETE. If the first answer is an unexplained 4xx, as some older servers give to a request without a session, it repeats the check with an initialize, and counts that only when the same initialize without a token is refused. See it fail Our acceptance suite has a test server with the bypass built in. It answers a request without a token with a proper 401 challenge and protected resource metadata, and serves any non-empty bearer token. This is the audit of that server, with no token passed: uvx mcpscore==1.25.0 'http://127.0.0.1:8094/mcp' Server requires authentication — running a partial audit of the observable surface. ... ✅ The observed HTTP 401 response carries a non-empty WWW-Authenticate challenge. ... ❌ A fabricated bearer token was accepted: tools/list answered HTTP 200, where the same request without a token gets HTTP 401. Observed HTTP status: 200. Fix: Validate every bearer token (signature or introspection, expiry, audience) before handling the request, and answer an invalid one with HTTP 401. A failed check must never fall back to a default identity. ... Audit finished. PARTIAL score: 13/27 from 10 of 92 checks — not comparable to a full audit. Without credentials, mcpscore runs a partial audit of what a server behind auth shows to anyone. The new check belongs there: it needs no token, because the point is what happens to a bad one. Switch the test server to accept only its real token and the check passes, with the three points back: # After: only the server's own token is served ✅ A fabricated bearer token was refused with HTTP 401. ... Audit finished. PARTIAL score: 16/27 from 10 of 92 checks — not comparable to a full audit. Before release we ran the rule against the auth-gated servers in our live acceptance set, among them Asana, Linear, Notion, Sentry, Stripe and GitHub Copilot. All of them refused the token. What the check cannot tell you A refused made-up token is necessary, not sufficient. Behind the front door, a server can still accept a real token issued for a different service, forward the client’s token to an upstream API, or grant more scope than the token carries. The spec forbids all three, and none of them is visible from outside. Code review and the gateway in front of the server are where those get caught. The check also needs a refusal to compare against. If your server answers a request without a token with a 500, mcpscore cannot tell it is behind auth, and the rule does not run. Fix that first: a server behind auth should answer a missing token with 401 and a WWW-Authenticate challenge, which mcpscore checks too. Gateways are where one bug reaches every server A gateway holds model-provider keys, upstream tokens and access policy for every application behind it, which is why it is worth putting there. It is also why a single fallback in its auth path exposed every connected MCP server at once. Treat LLM and MCP gateways like the API gateways and identity systems they have become: patch them on the advisory, keep them off the public internet unless they need to be there, and check the auth on the gateway and on each server behind it, since a request that gets past one should still be refused by the other. Check your server The rule runs on every HTTP audit in mcpscore 1.25.0, on the CLI and on mcpscore.dev. Point it at the URL you give clients, without a token: uvx mcpscore==1.25.0 'https://your-server.example/mcp' Add --sarif to put the finding in GitHub code scanning. The token check covers part of MCP07, insufficient authentication, in the OWASP MCP Top 10; the Origin check covers another. Every rule and its citation is in the rules reference, and the release notes list what changed. > Does your server refuse a token it never issued? > > One command, no credentials: mcpscore sends a made-up bearer token and reports whether your server lets it in. > > Auditing servers behind auth
LiteLLM: Privilege Escalation to Proxy Admin via Cross-Domain Reuse of the Salt Key
Show HN: Better Backlog: Filter GitHub issues with decision models
Two Open-Source Cloud Agents. One Is an API, One Is a Room Two Open-Source Cloud Agents. One Is an API, One Is a Room Source: MiniMax-AI/OpenAgentCore BerriAI/moyai License: MIT + MIT Made with AI-assisted production (script, voice, diagrams). No real people depicted. Not affiliated with the project owners. Claims from the project README; demos not re-run by us. #NoShortAnswers #AIAgents #OpenSource #CodingAgents #Shorts
How a Key Leaked in May Shipped Malware in October --- title: "How a Key Leaked in May Shipped Malware in October" published: true description: "The LiteLLM attack had no clever trick in it. Every step was a key or token that still worked long after anyone needed it. And the malware ran before Python imported anything." tags: security, python, devops, ai cover_image: dev-to-uploads.s3.us-east-2.amazonaws.com --- On 27 May 2026, someone committed a config file to a public repository by mistake. Inside it was the master key — the admin password, in effect — for one of LiteLLM's internal services. They rewrote the git history to hide it, and everyone moved on. Nobody switched the key off. It was first used on 10 October. That is 136 days later. Read what follows slowly, because nothing in it is clever. No bug, no zero-day. Every step is a key or token that still worked long after anyone needed it. The chain LiteLLM is an open-source gateway: one service your apps talk to instead of calling OpenAI, Anthropic, Bedrock and the rest directly. It routes requests, tracks spending and manages keys — which also means it holds every AI provider key a company owns. The leaked key unlocked an internal tool for letting AI agents open pull requests. When the attacker called it, the tool handed back a GitHub token belonging to an account that owned the whole organisation. Nobody had limited what that token could do. LiteLLM's write-up says it "was not associated with a service account and was not scoped down." It could do anything, anywhere in their GitHub. With that token, the attacker pushed a throwaway job into LiteLLM's main repository. GitHub Actions jobs can read the repository's secrets. So this one read them and posted them to a URL the attacker controlled: six, including a second maintainer's personal token. A second job took a third maintainer's. Both were deleted minutes later. Two days on, another round took fourteen more from the private repositories, including Anthropic, OpenAI and Bedrock keys. !Six-step chain from a key leaked on 27 May to a malicious release on 21 October, with no exploit at any step Each step is a credential that outlived its purpose. Source: LiteLLM postmortem, 23 Oct 2026. Then they waited nine days. On 21 October they pointed LiteLLM's release pipeline at a fake copy of the repository: Berri-AI/litellm, with a hyphen, instead of the real BerriAI/litellm. Branch protection blocked the change. The owner token overrode it. Publishing a release takes two people: one to start it, one to approve. The approver is normally a locked-down account tied to an office machine. That rule had been relaxed days earlier so any two engineers could do it. The attacker held three maintainers' tokens, so they started the release with one and approved with another — LiteLLM thinks to keep the audit log looking ordinary. The poisoned version went up on PyPI and Docker. A researcher spotted it twenty-seven minutes later. PyPI pulled it within two hours. The part that catches people The bad package installed three files. One is cook.pth, and that extension is the whole story. A .pth file in site-packages normally just lists extra folders for Python to search. But it has an old compatibility feature almost nobody thinks about. If a line in that file begins with import, Python runs it. Not when you import the package — at startup. The docs say these lines "are always run at Python startup... regardless of whether a particular module is actually going to be used." !Two flows compared: the expected one where malicious code runs only on import, and the real one where the site module executes the .pth import line at interpreter startup A .pth line beginning with import is run by Python's site module at startup. Source: Python docs, site. So the stealer never waited to be imported. It ran whenever any Python process started on that machine: your script, a test run, an unrelated project. Including this one: pip install --upgrade litellm pip is itself a Python program. Running it starts Python, which runs the stealer again, before the upgrade fixes anything. <!-- YOUR STORY (optional) --> What to do this week - Find the files without starting Python: find / -name 'cook.pth' -o -name 'sitecustomize.py' 2>/dev/null. Delete them and any matching .pyc files before you run pip. - Assume everything on that machine is gone, not just the AI keys. The stealer read cloud credentials, SSH keys, browser data, your database URL, and the config files for Codex, Claude, Gemini and Cursor. - Pin container images by digest, not tag. latest, main-latest and main-stable all pointed at the bad build. - Go find keys nobody needs any more. That is the root cause, and the only item here you can act on before an incident rather than after. Where this breaks down Calling this "just credentials" undersells one thing. The relaxed two-person rule mattered. Had the approver still been that locked-down office machine, three stolen tokens would not have been enough. The control was doing real work, right up to the moment someone loosened it for convenience. LiteLLM has said some of its early updates were wrong and more changes are coming, so treat this timeline as the best account so far. Takeaway Revoking a leaked key is not cleanup you do later. It is what decides whether a mistake in May becomes malware in October. What is the oldest credential still working in your infrastructure, and would you know if someone used it? Sources - LiteLLM postmortem: v1.107.0 compromise attack chain — 23 Oct 2026 - GitHub issue #47826, Upwind research — 21 Oct 2026 - Socket: attack timeline, workflows 11 days earlier — 23 Oct 2026 - Python docs: the site module and .pth files