Azure DevOps Artifacts の Universal パッケージを REST API で取得しようとしたところ、パッケージ名にコロン (:) を含めてパスに差し込むと「A potentially dangerous Request.Path value was detected from the client (:)」という 400 エラーで落ちる――この現象は、IIS/ASP.NET のリクエスト検証が「:」を危険文字として判定することが直接原因です。本記事では、なぜ起きるのかを噛み砕いて解説し、設計で回避する方法から最小範囲での設定緩和、実運用に耐える具体的な API 呼び出し例まで、コピー&ペーストで使える形で整理します。
エラーの実態と再現条件
以下のように、パッケージ名にコロンを含め、それを URL のパス(Path) に埋め込んだ場合に発生します。URL エンコードで %3A にしても防げません。
GET https://pkgs.dev.azure.com/{organization}/{project}/_apis/packaging/feeds/{feed}/packages/MavenProject.Automationtesting:Automationtesting/versions/1.0.0/content?api-version=7.0
--> HTTP/1.1 400 Bad Request
A potentially dangerous Request.Path value was detected from the client (:).
コロンを %3A にしても同様です(ASP.NET 側でデコード後に検証されるため)。
なぜ「:」が危険文字扱いされるのか
- ASP.NET(System.Web)には Request Path 検証 があり、既定で < > * % & : \ ? などをパスに含むことを拒否します。
- 検証は デコード後の値 に対して行われるため、
:を%3Aにしても効果がありません。 - 同じ「URL」でも パス と クエリ文字列 では扱いが異なり、パスは厳格に、クエリは比較的寛容に取り扱われます。
ありがちな誤解:URL エンコードで回避できる?
できません。ASP.NET はリクエストパイプラインの初期段階でパスをデコードし、その結果に対して危険文字チェックを行います。したがって %3A も : として判定され、同じ 400 を返します。
最短で分かる対処方針(結論)
| 対応 | 内容 | メリット / 注意点 |
|---|---|---|
| ① 危険文字を使わない(推奨) | パッケージ名や API の パス に : < > * % & \ ? を置かない。名称を変更する / 代替識別子に置換する / クエリ文字列・ヘッダーで渡す。 | 最も安全・シンプル。システム全体のセキュリティ方針に合致。 |
| ② ID or クエリで参照(実務的) | パッケージ 名 で検索(クエリ)→得た packageId で以降の API を実行。 コロンはパスに置かず、クエリ文字列にのみ入れる。 | URL 設計を変えずに通せる。Azure DevOps の REST 仕様にも沿う。 |
| ③ CLI / 公式ツールを使用 | az artifacts universal download や ArtifactTool を使い、名前やバージョンは引数で指定(内部で正しく処理される)。 | 手戻りが少ない。CI/CD パイプラインに組み込みやすい。 |
| ④ ASP.NET 側の検証緩和 | System.Web アプリでは web.config の httpRuntime で requestPathInvalidCharacters を調整。必要最小限のスコープに限定する。 | 強力だが攻撃面を広げる。内部ネットワーク限定など運用ガードが必須。 |
| ⑤ ASP.NET Core / IIS 設定 | ASP.NET Core は System.Web の検証がない。IIS リバースプロキシ配下なら requestFiltering を点検。基本は ①②③を優先。 | プラットフォーム差異に注意。アプリでのサニタイズを維持。 |
推奨アプローチ 1:危険文字をパスに使わない設計
最終的に運用コストが最も低く、脆弱性監査にも通しやすいのが「危険文字をパスに置かない」方針です。既に「MavenProject.Automationtesting:Automationtesting」のような命名を使っている場合は、次のいずれかに揃えると後が楽になります。
| 命名ポリシー | 例 | 備考 |
|---|---|---|
セグメント置換(: → --) | MavenProject.Automationtesting--Automationtesting | 人間可読。双方向マッピングが容易。 |
スラグ化(英数と-のみ) | mavenproject-automationtesting-automationtesting | 検索性・一貫性に優れる。 |
| ID 化(GUID/ULID) | 01HR6M4QJ8J7X3A9B4P2K7VY5N | 衝突回避・照合が高速。表示名は別フィールドで管理。 |
既存名称を維持したままでも、パスに載せない(後述のクエリ/ID 参照に切替)だけでこの問題は消えます。
推奨アプローチ 2:Azure DevOps REST を「クエリ+ID」で使う
Azure DevOps の Packaging API は、パッケージ名で検索(クエリ)して ID を取得し、その ID で詳細やバイナリへ進むという運用ができます。肝は「コロンをパスに含めない」こと。名前はクエリ文字列で渡せます。
手順(概略)
- 検索: パッケージ名に
:を含んだまま クエリ パラメータで検索(例:packageNameQuery)。 - 選定: レスポンスから対象パッケージの
id(GUID)と目的のversionを得る。 - 取得: 以降の API は
/packages/{packageId}/versions/{versionId}など ID ベースのパスで呼ぶ。パスにコロンが出ない。
curl 例(PAT 認証)
# 1) パッケージ名で検索(名前はクエリに渡す:パスに入れない)
ORG={organization}
PROJ={project}
FEED={feed}
PAT={your_pat}
curl -u :$PAT
"[https://pkgs.dev.azure.com/$ORG/$PROJ/_apis/packaging/feeds/$FEED/packages?packageNameQuery=$(python](https://pkgs.dev.azure.com/$ORG/$PROJ/_apis/packaging/feeds/$FEED/packages?packageNameQuery=$%28python) -c 'import urllib.parse;print(urllib.parse.quote("MavenProject.Automationtesting:Automationtesting"))')&protocolType=upack&api-version=7.0"
# 2) 応答から packageId を取り出す(例:id = "f1e2d3...-guid")
# 3) ID でバージョン一覧・コンテンツ取得(ID ベースのパスなので : は現れない)
PACKAGE_ID={packageId}
VERSION={version}
curl -L -u :$PAT
"[https://pkgs.dev.azure.com/$ORG/$PROJ/_apis/packaging/feeds/$FEED/packages/$PACKAGE_ID/versions?api-version=7.0](https://pkgs.dev.azure.com/$ORG/$PROJ/_apis/packaging/feeds/$FEED/packages/$PACKAGE_ID/versions?api-version=7.0)"
# Universal パッケージのコンテンツ取得(例)
VERSION_ID={versionId}
curl -L -u :$PAT
"[https://pkgs.dev.azure.com/$ORG/$PROJ/_apis/packaging/feeds/$FEED/packages/$PACKAGE_ID/versions/$VERSION_ID/content?api-version=7.0](https://pkgs.dev.azure.com/$ORG/$PROJ/_apis/packaging/feeds/$FEED/packages/$PACKAGE_ID/versions/$VERSION_ID/content?api-version=7.0)" -o package.upack
PowerShell 例(そのまま実行可能)
$org = "{organization}"
$proj = "{project}"
$feed = "{feed}"
$pat = "{your_pat}"
$base64Pat = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$pat"))
$headers = @{ Authorization = "Basic $base64Pat" }
# 1) 名前で検索(コロンはクエリにだけ含める)
$name = "MavenProject.Automationtesting:Automationtesting"
$q = [System.Uri]::EscapeDataString($name)
$searchUrl = "[https://pkgs.dev.azure.com/$org/$proj/_apis/packaging/feeds/$feed/packages?packageNameQuery=$q&protocolType=upack&api-version=7.0](https://pkgs.dev.azure.com/$org/$proj/_apis/packaging/feeds/$feed/packages?packageNameQuery=$q&protocolType=upack&api-version=7.0)"
$search = Invoke-RestMethod -Headers $headers -Uri $searchUrl -Method GET
$pkg = $search.value | Select-Object -First 1
$packageId = $pkg.id
# 2) バージョン情報
$versionsUrl = "[https://pkgs.dev.azure.com/$org/$proj/_apis/packaging/feeds/$feed/packages/$packageId/versions?api-version=7.0](https://pkgs.dev.azure.com/$org/$proj/_apis/packaging/feeds/$feed/packages/$packageId/versions?api-version=7.0)"
$versions = Invoke-RestMethod -Headers $headers -Uri $versionsUrl -Method GET
$version = $versions.value | Select-Object -First 1
$versionId = $version.id
# 3) コンテンツ取得
$contentUrl = "[https://pkgs.dev.azure.com/$org/$proj/_apis/packaging/feeds/$feed/packages/$packageId/versions/$versionId/content?api-version=7.0](https://pkgs.dev.azure.com/$org/$proj/_apis/packaging/feeds/$feed/packages/$packageId/versions/$versionId/content?api-version=7.0)"
Invoke-WebRequest -Headers $headers -Uri $contentUrl -OutFile "package.upack"
C#(HttpClient)例:クエリ→ID→取得の流れ
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var org = "{organization}";
var proj = "{project}";
var feed = "{feed}";
var pat = "{your_pat}";
using var http = new HttpClient();
var token = Convert.ToBase64String(Encoding.ASCII.GetBytes(":"+pat));
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token);
// 1) 名前検索(クエリ)
var name = "MavenProject.Automationtesting:Automationtesting";
var url = $"https://pkgs.dev.azure.com/{org}/{proj}/_apis/packaging/feeds/{feed}/packages" +
$"?packageNameQuery={Uri.EscapeDataString(name)}&protocolType=upack&api-version=7.0";
using var res1 = await http.GetAsync(url);
res1.EnsureSuccessStatusCode();
using var doc1 = await JsonDocument.ParseAsync(await res1.Content.ReadAsStreamAsync());
var packageId = doc1.RootElement.GetProperty("value")[0].GetProperty("id").GetString();
// 2) バージョン取得(ID ベース)
var url2 = $"https://pkgs.dev.azure.com/{org}/{proj}/_apis/packaging/feeds/{feed}/packages/{packageId}/versions?api-version=7.0";
using var res2 = await http.GetAsync(url2);
res2.EnsureSuccessStatusCode();
using var doc2 = await JsonDocument.ParseAsync(await res2.Content.ReadAsStreamAsync());
var versionId = doc2.RootElement.GetProperty("value")[0].GetProperty("id").GetString();
// 3) コンテンツダウンロード(ID ベース)
var url3 = $"https://pkgs.dev.azure.com/{org}/{proj}/_apis/packaging/feeds/{feed}/packages/{packageId}/versions/{versionId}/content?api-version=7.0";
var bytes = await http.GetByteArrayAsync(url3);
await System.IO.File.WriteAllBytesAsync("package.upack", bytes);
}
}
推奨アプローチ 3:CLI / ArtifactTool を使う
Azure CLI には Universal パッケージ専用のコマンドがあります。名前にコロンを含む場合も、引数として渡すだけで済み、内部で適切に処理されます。
az artifacts universal download \
--organization "https://dev.azure.com/{organization}" \
--project "{project}" \
--feed "{feed}" \
--name "MavenProject.Automationtesting:Automationtesting" \
--version "1.0.0" \
--path .
CI/CD(Azure Pipelines, GitHub Actions, 他)でも、CLI/ArtifactTool をタスクとして呼ぶだけなので、アプリ側・サーバー側設定を触らず短時間で安定運用に入れます。
やむを得ずサーバー側で緩和する場合(System.Web / IIS)
レガシーな System.Web(.NET Framework)アプリで、パスに「:」を含む URL をどうしても受けたい場合は、web.config で requestPathInvalidCharacters を調整します。必ず限定スコープ(特定の仮想ディレクトリ/コントローラ配下)で適用してください。
最小スコープでの緩和例(location 要素で限定)
<configuration>
<location path="api/packages">
<system.web>
<!-- 既定の危険文字からコロンのみ除外(他は既定のまま) -->
<httpRuntime requestPathInvalidCharacters="<,>,* ,%,&,\,?" requestValidationMode="2.0" />
<pages validateRequest="false" />
</system.web>
</location>
</configuration>
重要:検証を緩めるほど XSS やオープンリダイレクト等の攻撃面が広がります。少なくとも次を併用してください。
- IP 制限・認証の前段適用(リバースプロキシ/WAF)。
- 入力値の サーバー側 サニタイズとエンコード(HTML・JavaScript・URL・SQL の各コンテキスト)。
- ルーティングで許容パターン(正規表現)を厳密化。
- Fuzzing を含む脆弱性テストの自動化(CI に統合)。
ASP.NET MVC / Web API での局所無効化
特定のアクションだけで一時的に受けたい場合は次のような 属性 で範囲を絞る選択肢もあります。
[ValidateInput(false)]
public ActionResult Download(string name, string version) { ... }
ただし、モデルバインド経由の取り回しやビュー出力時のエンコードを厳密に行わないと危険です。基本は「パスに入れない」を守るのが正解です。
ASP.NET Core の場合の要点
- ASP.NET Core は System.Web の Request Validation を持ちません。Kestrel 単体ではこのエラーは発生しません。
- IIS リバースプロキシ配下であれば、IIS の requestFiltering(
system.webServer/security/requestFiltering)の影響を受けます。基本設定はコロンで詰まりにくいですが、独自の禁止シーケンス指定や WAF ルールでブロックされることはあります。 - いずれにせよ、パスにコロンを置かない・ID ベースのパス・クエリで名前を渡すの三原則は Core でも変わりません。
プロキシ/ゲートウェイ/WAF 配下での落とし穴
- リバースプロキシや API ゲートウェイが 正規化(Normalization) を行うと、重複エンコードやデコード順序の違いで 400 になることがあります。二重エンコードを禁止してください。
- ロギングは エッジ(プロキシ)とバックエンド(IIS/アプリ) の双方で有効化し、同一の相関 ID を通して追跡できるようにします。
- WAF のシグネチャで
:を含むパスパターンがブロックされていないか確認します(多くの場合、クエリで渡せば回避できます)。
実運用に落とすための設計チェックリスト
- URL 設計: パス・クエリ・ヘッダーの責務分担を明文化(パス=リソースの ID のみ)。
- 命名規約: 許容文字一覧・置換規則・衝突回避ルール(大文字小文字、トリム、NFC/NFD 正規化)。
- 互換マッピング: 旧名称(コロンあり)→新名称(置換後)を辞書管理。移行期間は 301/308 リダイレクト。
- 監査ログ: 入力(オリジナル)と正規化後を両方記録。GDPR/個人情報方針に従う。
- テスト: 危険文字を含むパラメータの e2e テスト、フェイルオープンが起きないことの検証。
よくある質問(FAQ)
Q1. URL を「ダブルエンコード」すれば通る?
通りません。サーバーのどこかで最終的にデコードされ、結局コロンとして判定されます。ダブルエンコードは別の 400(無効な要求)や 404.11(IIS の二重エスケープ拒否)を招くため非推奨です。
Q2. パスではなくクエリに入れれば本当に安全?
少なくとも本件の例外は回避できます。ただしクエリに入れても、アプリ側の入力検証・エンコードは必須です。「パスに置かない」ことと「入力を無条件で信頼する」ことは別の話です。
Q3. ルータ(ASP.NET MVC/Core)のルート制約で許すことはできる?
ルータだけでは ASP.NET / IIS の前段検証を超えられません。サーバー全体の設定を緩めるか、パスに置かない設計へ改修してください。
Q4. 既に多数のコロン付きパッケージがある。最小工数は?
まずは REST を「クエリ→ID」に切替え、ダウンロードは CLI/ArtifactTool に寄せます。並行して名称の正規化(置換 or ID 化)を始めるのが運用と安全性のバランスが良いです。
Q5. Universal 以外(npm、NuGet 等)でも同じ?
「パスに危険文字を置かない」「ID で参照」はどのプロトコルでも有効です。プロトコル固有の API でも、検索→ID→取得の流れは大差ありません。
実装テンプレート:安全なエンドポイント設計サンプル
アプリ側でコロン入り名称を受けつつ、パスには置かないエンドポイント設計例です。
# 例:GET /api/packages/content?name={複合名}&version={ver}
# name は "MavenProject.Automationtesting:Automationtesting" のようにコロン可
# サーバー側で name を検証・正規化し、内部では ID に解決して取得
GET /api/packages/content?name=MavenProject.Automationtesting%3AAutomationtesting&version=1.0.0
この場合のルール:
- パスは
/api/packages/content固定(危険文字なし)。 - クエリの
nameは入力検証(長さ・文字種・許容表現)をかけ、内部でpackageIdに解決。 - 監査ログにはオリジナルの
nameと解決後packageIdを併記。
セキュリティ観点のまとめ
| 選択肢 | 攻撃面(増減) | 推奨度 |
|---|---|---|
| パスに危険文字を置かない | 増えない | 最高 |
| クエリで渡す / ID 参照 | 適切な入力検証が前提 | 高 |
| サーバー設定で緩和 | 増える(防御層が薄くなる) | 低(限定スコープのみ) |
最終まとめ
- エラーの正体は、ASP.NET の Request.Path 検証が「:」を危険文字として拒否しているため。
- 最優先は「パスに危険文字を置かない」設計。名称変更が難しければ クエリや ID 参照に切替える。
- Universal パッケージの取得は「名前で検索 → ID で取得」が最も現実的。CLI/ArtifactTool も有効。
- 設定緩和は最小範囲・短期間にとどめ、アクセス制限・サニタイズ・脆弱性テストを必ず併用。
付録:すぐ使えるコピペ集
web.config(System.Web・最小緩和の雛形)
<configuration>
<location path="api/packages">
<system.web>
<httpRuntime requestPathInvalidCharacters="<,>,* ,%,&,\,?" requestValidationMode="2.0" />
<pages validateRequest="false" />
</system.web>
</location>
</configuration>
Azure CLI(Universal ダウンロード)
az artifacts universal download \
--organization "https://dev.azure.com/{organization}" \
--project "{project}" \
--feed "{feed}" \
--name "MavenProject.Automationtesting:Automationtesting" \
--version "1.0.0" \
--path .
REST 呼び出しの柱(疑似コード)
1) GET /_apis/packaging/feeds/{feed}/packages?packageNameQuery={name}&protocolType=upack
2) GET /_apis/packaging/feeds/{feed}/packages/{packageId}/versions
3) GET /_apis/packaging/feeds/{feed}/packages/{packageId}/versions/{versionId}/content
PowerShell(最短 10 行版)
$h=@{Authorization="Basic "+[Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$env:PAT"))}
$base="https://pkgs.dev.azure.com/$env:ORG/$env:PROJ/_apis/packaging/feeds/$env:FEED"
$pkg=Invoke-RestMethod -Headers $h -Uri "$base/packages?packageNameQuery=$([uri]::EscapeDataString($env:NAME))&protocolType=upack&api-version=7.0"
$pid=$pkg.value[0].id
$ver=Invoke-RestMethod -Headers $h -Uri "$base/packages/$pid/versions?api-version=7.0"
$vid=$ver.value[0].id
Invoke-WebRequest -Headers $h -Uri "$base/packages/$pid/versions/$vid/content?api-version=7.0" -OutFile "package.upack"
この問題は「エンコード・トリックで乗り切る」類の話ではなく、URL 設計と取得フローを正すことで根本的に解消できます。まずは パスから危険文字を追放し、クエリ+ID 参照へ切り替える――それが最短で堅牢な道です。

コメント