ASP.NET Coreでネットワーク共有(UNC)上のPDFテンプレートを読み込もうとしたら、DirectoryNotFoundExceptionが発生し、なぜかC:\を探してしまう——そんな現象は「URLパス」と「物理パス」の取り違えが原因のことがほとんどです。本記事では原因の切り分けと、IIS権限まで含めた実践的な解決策をまとめます。
起きている現象を整理する
よくある構成として、Program.cs(または Startup)でUNC上のPDFを静的ファイルとして公開し、コントローラー側ではPDFライブラリ(例:PdfDocument)でテンプレートPDF(空のPDF)を開いて加工する、という流れがあります。
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
@"\\tcs-trs\shared\2024\LanguageForms\BlankPDFForm"),
RequestPath = "/BlankPDFForm"
});
しかしコントローラーで、次のように「静的ファイルのURL」をそのまま渡すと例外が出ます。
// NG例:これはURLパス(リクエストパス)であって物理パスではない
var blankPdf = "/BlankPDFForm/TestForm.pdf";
// 例:PdfDocumentに文字列パスを渡して開こうとする
var doc = new PdfDocument(blankPdf);
例外メッセージを見ると、あたかも C:\BlankPDFForm\TestForm.pdf のような「ローカルドライブ」を探しに行っているように見え、UNCを設定しているのになぜ?となりがちです。
原因の本質:URLパスと実ファイルパスは別物
結論から言うと、UseStaticFiles の RequestPath(例:/BlankPDFForm)は「Web上のURLとして公開するためのパス」であり、サーバー内部でファイルを開くための「物理パス」ではありません。
静的ファイルミドルウェアは、HTTPリクエストを受けたときにだけ「URL → FileProviderの物理ファイル」へマッピングします。つまり、あなたのコードが File.Open やPDFライブラリでファイルを開くときに、静的ファイルの設定が自動で効くわけではない、というのがポイントです。
「/で始まる文字列」がC:\に見える理由
"/BlankPDFForm/TestForm.pdf" はURLとしては正しいのですが、ファイルI/Oの世界では「ルート相対(ドライブ未指定の絶対パスっぽいもの)」として扱われやすいため、結果として「そのマシンの既定ドライブ(多くはC:)」に結び付けられます。
実際に、Windows上で次のコードを動かすと挙動が理解しやすくなります。
var urlLike = "/BlankPDFForm/TestForm.pdf";
Console.WriteLine(Path.IsPathRooted(urlLike)); // True になりやすい
Console.WriteLine(Path.GetFullPath(urlLike)); // 例:C:\BlankPDFForm\TestForm.pdf
つまり、UNCを設定しているのにC:\を見に行くのではなく、そもそもPDFライブラリが「UNCではない別の文字列」をファイルパスとして解釈している、という構図です。
| 混同しやすいもの | 例 | 用途 | これを渡す先 |
|---|---|---|---|
| URLパス(RequestPath配下) | /BlankPDFForm/TestForm.pdf | ブラウザやHTTPクライアントがアクセスするためのパス | リンク生成、静的ファイル配信(HTTP) |
| 物理パス(UNC) | \\tcs-trs\shared\2024\LanguageForms\BlankPDFForm\TestForm.pdf | サーバー上で実ファイルを開くためのパス | PDFライブラリ、File.OpenRead 等 |
解決策:PDFライブラリにはUNCの物理パス(またはストリーム)を渡す
解決の方向性はシンプルで、PDFテンプレートの実体を開くときは「UNCの物理パス」を使うことです。静的ファイルとして公開しているかどうかは関係ありません(公開する/しないは要件次第)。
appsettings.json にUNCパスを持たせる
テンプレートが置かれている共有パスは、コードに直書きせず設定で管理するのが運用上安全です。JSONではバックスラッシュのエスケープが必要です。
{
"BlankPDFConfig": "\\\\tcs-trs\\shared\\2024\\LanguageForms\\BlankPDFForm"
}
コントローラー側で物理パスを組み立てて開く
Path.Combine で結合し、存在確認と例外ハンドリングを入れておくと原因特定が早くなります。
public class FormsController : Controller
{
private readonly IConfiguration _config;
private readonly ILogger<FormsController> _logger;
public FormsController(IConfiguration config, ILogger<FormsController> logger)
{
_config = config;
_logger = logger;
}
public IActionResult Generate()
{
var baseDir = _config["BlankPDFConfig"];
if (string.IsNullOrWhiteSpace(baseDir))
{
throw new InvalidOperationException("BlankPDFConfig is not configured.");
}
// 例:テンプレート名は固定またはホワイトリストから選ぶ(ユーザー入力を直結しない)
var templateFileName = "TestForm.pdf";
var physicalPath = Path.Combine(baseDir, templateFileName);
if (!System.IO.File.Exists(physicalPath))
{
_logger.LogError("PDF template not found. path={Path}", physicalPath);
return NotFound();
}
// PDFライブラリが「ファイルパス」を受け取れるなら、そのまま渡す
// var doc = new PdfDocument(physicalPath);
// より安全で汎用的なのは「ストリーム」で渡す方法
using var stream = System.IO.File.OpenRead(physicalPath);
var doc = new PdfDocument(stream); // ※ライブラリに合わせて Open/Load 等に変更
// ...加工処理...
return Ok();
}
}
特に、PDFライブラリにストリームを渡せるならストリームを推奨します。理由は次の通りです。
- パス解釈の癖(「/で始まるとC:\になる」等)の影響を受けにくい
- テスト時にメモリストリームへ差し替えやすい
- 将来的にUNC以外(Azure Files、S3互換ストレージ、DB等)に置き換える余地が残る
UNCパスの組み立てで事故を防ぐポイント
共有パスを Path.Combine で組み立てるのは基本として、次の落とし穴も押さえておくと再発防止になります。
| 落とし穴 | 例 | 何が起きるか | 対策 |
|---|---|---|---|
| 結合する側に先頭スラッシュが混ざる | Path.Combine(baseDir, "/TestForm.pdf") | baseDir が無視され、ドライブ未指定のルート相対になりやすい | TrimStart('/', '\\') で先頭を落としてから結合 |
| 文字列連結で区切り文字が二重/不足 | baseDir + "\\" + file | 環境差でパスが崩れやすい | Path.Combine を使う |
| ドライブレター前提の実装 | Z:\Templates\TestForm.pdf | IIS/サービスではドライブ割り当てが存在しないことがある | 常にUNC(\\server\share)を使う |
private static string CombineUnc(string baseDir, string relativeOrFileName)
{
// 先頭の / や \\ が混ざると baseDir が無視される事故を防ぐ
var safe = relativeOrFileName.TrimStart('/', '\\');
return Path.Combine(baseDir, safe);
}
URLから物理ファイルを“逆引き”したいなら IFileProvider を使う
「URLパスからテンプレートを指定したい」「静的ファイル公開の設定と同じ場所を参照したい」という場合は、URL文字列をそのままPDFライブラリへ渡すのではなく、同じ IFileProvider を使ってファイル情報を取得し、ストリームを開いて渡します。
// 例:DIでIFileProviderを登録しておき、同じルート配下を参照する
// services.AddSingleton<IFileProvider>(
// new PhysicalFileProvider(@"\\tcs-trs\shared\2024\LanguageForms\BlankPDFForm"));
public class FormsController : Controller
{
private readonly IFileProvider _pdfProvider;
public FormsController(IFileProvider pdfProvider)
{
_pdfProvider = pdfProvider;
}
public IActionResult Generate()
{
// RequestPath(/BlankPDFForm)はHTTP用。ここでは除いた「相対パス」を使う
var relative = "TestForm.pdf";
var fileInfo = _pdfProvider.GetFileInfo(relative);
if (!fileInfo.Exists)
{
return NotFound();
}
using var stream = fileInfo.CreateReadStream();
var doc = new PdfDocument(stream);
// ...加工処理...
return Ok();
}
}
この方法なら、テンプレートの物理配置を変更しても IFileProvider の差し替えで吸収しやすくなります。
重要:ネットワーク共有は「実行ユーザー権限」でしか読めない
UNCパスのPDFを開く処理が動かない場合、パスの組み立て以前にアクセス権(共有権限+NTFS権限)でつまずいているケースが非常に多いです。
ポイントは、開きに行くのは「あなたが開発機で実行したときのユーザー」ではなく、「本番サーバーでアプリが動いているユーザー」だということです。
| ホスティング形態 | 実行ユーザーの例 | 共有への権限付与先 | よくある症状 |
|---|---|---|---|
| IIS(ApplicationPoolIdentity) | 既定ではアプリプールID(ネットワークではマシンアカウントとして見えることがある) | 共有/NTFSにサーバーマシン(例:DOMAIN\WEB01$)または専用ドメインアカウント | ローカルでは動くがサーバーではNotFound/Unauthorizedになる |
| IIS(ドメインサービスアカウントに変更) | DOMAIN\svc_webapp 等 | そのドメインアカウントに読み取り権限 | 権限設定で解決しやすいが、パスワード管理が必要 |
| Windowsサービス(Kestrelをサービス化) | LocalSystem / NetworkService / 指定アカウント | サービス実行アカウントに権限 | NetworkServiceだと権限不足になりやすい |
最低限チェックすべき権限ポイント
- 共有権限(共有の「共有」タブ)とNTFS権限(セキュリティタブ)の両方に読み取りが付いているか
- PDFテンプレートが置かれたフォルダだけでなく、親フォルダの継承設定で意図しない拒否が入っていないか
- IISならアプリプールのIDを把握し、実際にその権限でUNCにアクセスできるか
- パスが長すぎないか、ファイル名に禁止文字が混ざっていないか
権限が原因の場合、例外が UnauthorizedAccessException になることもありますが、ライブラリや実装次第で DirectoryNotFoundException のように見えることもあるため、「例外の型だけ」で決め打ちしないのがコツです。
どうしても別資格情報で共有に接続したい場合の回避策
ポリシー上、アプリの実行アカウントに共有権限を付与できない場合や、特定の資格情報でのみアクセスさせたい場合があります。その場合、WNetAddConnection2 を使って共有へ接続してからファイルへアクセスする回避策が取られることがあります。
ただし、この方法は運用上のリスクもあります。たとえば、資格情報の安全な保管、プロセス全体への影響、同時実行時の管理などです。採用するなら、まずは「実行アカウントへ権限付与」や「アプリプールIDの変更」を検討し、それでも無理な場合の最終手段にしてください。
NetworkConnection(例)
using System;
using System.ComponentModel;
using System.Net;
using System.Runtime.InteropServices;
public sealed class NetworkConnection : IDisposable
{
private readonly string _networkName;
public NetworkConnection(string networkName, NetworkCredential credentials)
{
_networkName = networkName;
var netResource = new NetResource
{
Scope = ResourceScope.GlobalNetwork,
ResourceType = ResourceType.Disk,
DisplayType = ResourceDisplaytype.Share,
RemoteName = networkName
};
var userName = string.IsNullOrEmpty(credentials.Domain)
? credentials.UserName
: $@"{credentials.Domain}\{credentials.UserName}";
var result = WNetAddConnection2(netResource, credentials.Password, userName, 0);
if (result != 0)
{
throw new Win32Exception(result);
}
}
public void Dispose()
{
WNetCancelConnection2(_networkName, 0, true);
}
[DllImport("mpr.dll")]
private static extern int WNetAddConnection2(NetResource netResource, string password, string username, int flags);
[DllImport("mpr.dll")]
private static extern int WNetCancelConnection2(string name, int flags, bool force);
[StructLayout(LayoutKind.Sequential)]
private class NetResource
{
public ResourceScope Scope;
public ResourceType ResourceType;
public ResourceDisplaytype DisplayType;
public int Usage;
public string LocalName;
public string RemoteName;
public string Comment;
public string Provider;
}
private enum ResourceScope : int { Connected = 1, GlobalNetwork, Remembered, Recent, Context }
private enum ResourceType : int { Any = 0, Disk = 1, Print = 2 }
private enum ResourceDisplaytype : int { Generic = 0x0, Domain = 0x01, Server = 0x02, Share = 0x03 }
}
使う側は次のようになります。
var shareRoot = @"\\tcs-trs\shared";
var credential = new NetworkCredential("share-user", "password", "DOMAIN");
using (new NetworkConnection(shareRoot, credential))
{
var physicalPath = @"\\tcs-trs\shared\2024\LanguageForms\BlankPDFForm\TestForm.pdf";
using var stream = System.IO.File.OpenRead(physicalPath);
var doc = new PdfDocument(stream);
// ...加工...
}
実運用ではパスワードを appsettings.json に平文で置かず、環境変数、Windowsの資格情報管理、Azure Key Vault等のシークレット管理を使ってください。また、共有先が変わったときのローテーション手順も含めて設計します。
よくある間違いと対処の早見表
| 症状 | ありがちな原因 | 確認ポイント | 対処 |
|---|---|---|---|
| 例外がC:\配下を探している | URLパス(/BlankPDFForm/…)をファイルパスとして渡している | Path.GetFullPath の結果がC:\になっていないか | UNCの物理パスを組み立てて渡す/ストリームで渡す |
| ローカルでは動くがIISで動かない | アプリ実行ユーザーに共有権限がない | アプリプールID、サーバーのマシンアカウントでアクセスできるか | 共有+NTFSに権限付与/アプリプールIDをドメインアカウントへ |
| 開発機は動くが本番だけ失敗する | 本番の設定値が違う、UNCのエスケープミス | 実際に読み込もうとしているフルパスをログで確認 | 設定を統一し、JSONの\\\\を見直す |
| たまに失敗する/遅い | ネットワーク遅延、共有側の負荷、ファイルロック | SMB遅延、共有サーバーの負荷、同時アクセス数 | テンプレをローカルキャッシュ、リトライ、監視を導入 |
運用で効く実践ノウハウ
テンプレート名はホワイトリストで管理する
テンプレートPDFを「ユーザー入力のファイル名」で直接指定すると、パス・トラバーサル(..\)の温床になります。ファイル名を受け取る必要がある場合でも、次のように許可されたテンプレートだけを選択させる設計が安全です。
private static readonly HashSet<string> AllowedTemplates = new(StringComparer.OrdinalIgnoreCase)
{
"TestForm.pdf",
"LanguageFormA.pdf",
"LanguageFormB.pdf"
};
private static string GetSafeTemplateFileName(string requested)
{
var fileName = Path.GetFileName(requested); // ディレクトリ要素を捨てる
if (!AllowedTemplates.Contains(fileName))
{
throw new InvalidOperationException("Invalid template name.");
}
return fileName;
}
「静的ファイルとして公開するか」は要件で決める
テンプレートPDFは、基本的にはサーバー内部処理にしか使いません。外部公開が不要なら、UseStaticFilesで公開しない方が安全でシンプルです。逆に「ユーザーがテンプレートをダウンロードする要件」があるなら公開しても構いませんが、公開範囲を限定し、アクセス制御やファイル名の予測可能性にも注意してください。
ネットワーク共有に依存するなら監視とフォールバックを用意する
UNC先が落ちる・遅くなると、PDF生成が全滅します。以下を用意すると、障害時の調査が一気に楽になります。
- テンプレート存在確認(起動時やヘルスチェック)
- 共有への読み取りテスト(サービスアカウントで)
- テンプレートのローカルキャッシュ(更新頻度が低い場合)
- 例外ログに「フルパス」「実行環境」「テンプレ名(ただし個人情報は除く)」を出す
まとめ
/BlankPDFForm/...はURLであり、PDFライブラリに渡すファイルパスではない- UNC上のPDFを加工するなら、UNCの物理パスを組み立てて渡す(可能ならストリームで渡す)
- ネットワーク共有はアプリの実行ユーザー権限がすべて。IISならアプリプールIDやサービスアカウントに権限を付与する
- 別資格情報で接続する回避策はあるが、シークレット管理と運用設計が必須

コメント