はじめに
現在、Google AI Studio で画面(フロントエンド)を構築し、ローカル PC の SpringBoot で WebAPI サーバーを開発して連携させる検証を行っています。
その際、ブラウザ上で外部サイトの JavaScript から localhost へのアクセスがブロックされる問題に遭遇しました。この挙動のセキュリティ的な背景と、回避策について解説します。
すでに CORS をご存じの方には一言だけお伝えしたいです。
CORS エラーとして報告されるけど実際は PNA でブロックされていることがあります。CORSプリフライト(OPTIONS)も飛んでないときはこの記事の PNA が参考になるかもしれません。
これは Google AI Studio へ「〇〇サイトを作ってログイン画面を作ってログインしたらダッシュボードへ移動するようにして」と指示して作らせた画面です。
通信に失敗して「CORS許可設定をご確認ください」とエラーメッセージが出ていますね?CORSとはなんでしょうか?
CORS
Google AI Studio は外部サーバ(クラウド上)にあり、外部サイトのコードがブラウザ上で動くため他の場所(localhostも他の場所ですね) への http アクセスができません。ブラウザがアクセス自体をブロックします。
例えば次のようなコードはブラウザ上のコードとしては機能しません。(DevTools 上では機能しますこれがまた話をややこしくする…)
const response = await fetch('http://localhost:8080/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(credentials)
});
理由は単純に危ないからです。CSRF攻撃 ( https://www.ipa.go.jp/security/vuln/websecurity/csrf.html )一般を防ぐために、他の場所への Http アクセスは基本的には禁じられています。
ですがそれでは不便なので、 CORS という仕組みで信用できる場所の Javascript からは http アクセスができるようにしています。
Simple Request と呼ばれる GET や OPTIONS, form の POST などの単純なリクエストは Javascript の存在する場所を Origin ヘッダをつけて送信し、レスポンスヘッダに許可を与えるヘッダがあればレスポンスを読み取ります。許可がなければレスポンスは読み取りません。
application/json などの影響を与えそうな POST リクエスト前にCORSプリフライトとしてブラウザが OPTIONS で自動的にアクセスし、アクセス先のサーバが一度許可を出し、許可があったらブラウザは本来のリクエストを通す、という防ぎ方をします。許可がなければリクエスト自体を送りません。
このとき「どこのリソース ( Origin ) からのアクセスなら許可するか?を設定して守ることもできます。例えば Google AI Studio のJavascriptコードにはアクセス許可を出すけど他の場所から読み込んだJavascriptコード(広告とかに入ってます)にはアクセスさせない、といったことができます。
これは Spring Security の機能( https://spring.pleiades.io/spring-security/reference/servlet/integrations/cors.html )
などとして実装されており、簡単に使うことができます。
@Configuration
@EnableWebSecurity
open class WebSecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
// if Spring MVC is on classpath and no CorsConfigurationSource is provided,
// Spring Security will use CORS configuration provided to Spring MVC
cors { }
// ...
}
return http.build()
}
}
ブラウザで POST リクエストを送ると、先に OPTIONS が送られていることが判ります。


この OPTIONS が通り許可が出たらブラウザは POST も通します。許可が出なければ POST を止めます。POST 先が CORS 対応をしていないときも許可が出ませんので安全です!
これらの対応をすることで外部サイトの WebApp とローカルのサーバとで通信ができるようになります。
なお、ブラウザ側の Javascript コードでやることは特にありません。ブラウザがすべて処理します。
CORSでブロックされたかは CORSプリフライト要求(つまり OPTIONS の HttpRequest)だけがあったかで区別することができます。
開発時に困ったら OPTIONS のリクエストとレスポンスが正しいヘッダを送っているか確認しましょう。
ブラウザ上の Private Network Access (PNA) ポリシー
しかし、CORSとは別に、ブラウザは「外部ネットワークのサイトからローカルネットワークのリソース」へのアクセスを厳しく制限しています。それが Private Network Access (PNA) ポリシーです。
これは CORS と同じように(ただし Simple Request でも)ブラウザが自動的にプリフライトリクエストを送るのですが、
この時に CORS とは別なヘッダー Access-Control-Request-Private-Network: true を付与する必要があります。
サーバー側がこのプリフライト要求に対して、Access-Control-Allow-Private-Network: true ヘッダーを返さないと、
ブラウザが接続を遮断し、開発ツールのネットワークログ上では net::ERR_FAILED となります。
このプリフライトは通るはず…なのですが、後述する PNA の厳格化により送信元がHTTPSなのに宛先がHTTPだと、プリフライトすら飛ばずに即ブロックされます。
CORSで止められた場合は OPTIONS だけ送られて本命のリクエストが送られませんが、
プリフライト OPTIONS リクエストすらなかった場合は PNA で止まっているのでブラウザの設定を変えます。
chrome://flags にどちらかのセッティングがあるはずです。※Chromeのバージョンによっては項目名が異なる、またはフラグが廃止されている場合があります。
- Block insecure private network requests
- Local Network Access Checks
これらを Disabled にすると PNA が利かなくなり通るようになります。
ただし、ローカルネットワークではルーターの設定画面や開発中のサーバなど、単純な GET や POST を受信するだけで危険なものがあります。
Disabled にするときは別タブで外部サイトを開いたりしないようにし、作業が終わったら必ず Default(Enabled) に戻しましょう。
PNA 仕様の厳格化
PNA の厳格な仕様として、「外部ネットワーク(Public)からローカル(Private)にアクセスする場合、送信元の外部サイトは HTTPS でなければならない(セキュアコンテキストの義務化)」というルールが段階的に適用されています。
先述した「送信元がHTTPSで、宛先がHTTPのローカルの場合、ブラウザが接続自体を即座にブロックする」
のも含め、この厳格化は開発中に色々と踏む可能性があります。調べなおさないといけないですね。
SpringBoot の CORS は何をやっているのか?
CORS 対応はレスポンスで適切なヘッダを返し、ブラウザがそれを見てアクセスを許可することで動きます。
別にフィルタを書くと下のようになります。
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class PrivateNetworkAccessFilter : OncePerRequestFilter() {
// 許可するオリジンのリストをここで定義
private val allowedOrigins = listOf(
"https://example.com",
"https://クラウド上のURL.run.app"
)
override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain ) {
val origin = request.getHeader("Origin")
// CORSへの対応: オリジンが許可リストに含まれている場合のみヘッダーを設定
if (origin != null && allowedOrigins.contains(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin)
response.setHeader("Access-Control-Allow-Credentials", "true")
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Access-Control-Request-Private-Network")
// PNA プリフライト要求への対応
if ("true" == request.getHeader("Access-Control-Request-Private-Network")) {
response.setHeader("Access-Control-Allow-Private-Network", "true")
}
}
// プリフライトリクエスト(OPTIONS)はここで処理を終了
if ("OPTIONS".equals(request.method, ignoreCase = true)) {
response.status = HttpServletResponse.SC_OK
return
}
filterChain.doFilter(request, response)
}
}
CORS と PNA は別の問題で、localhost の場合はその両方にブロックされる可能性があるということですね。
余談。それでも想定外のレスポンスの時は AI が CORS エラーメッセージを吐いた。
さてこれで解決したと思ったら Google AI Studio に生成させたログインが通りません。
AI に生成させたとはいえ JWT トークンを取得して保存するだけの簡単な処理なのですが…?
responseData = JSON.parse(response.text());
「まさか勝手にJSON扱いされているとは…!」
実は、SpringBoot側のJWTトークンは text/plain で返していたのですが、AIが生成したフロント側のコードが勝手にJSONとしてパースしようとし、失敗していました。
しかも、そのパースエラー(JavaScript内部のエラー)のcatch節で、AIが「CORS許可設定をご確認ください」というエラーメッセージを出力するようにコードを書いていたため、原因特定に時間がかかってしまいました。
setErrorMsg(
`接続エラー: "${apiBaseUrl}/login" にアクセスできませんでした。ローカルサーバーが起動しているか、CORS許可設定をご確認ください。`
);
AIに次のように指示を出して解決です。
ログインサーバはヘッダ「Content-Type: text/plain;charset=UTF-8」を返しますので、このヘッダの時はテキストとして読んでJWTトークンを保存するようにしてください。
Google AI Studio 「〇〇のサイトを作るので画面を作ってください。ログインしたらダッシュボードに移動します」と指示するだけで
実に立派な(ダッシュボードや指示した覚えのないコンテンツ一覧などになんかそれっぽいダミーデータが表示されてる…!)画面を作ってくれますが
通信処理はさすがに正しく指示をしないと正しく動きませんね!
