From 07c5a456df4245087af80ad3f06b7ddea3ae8ffb Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:06:15 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(devcontainer):=20=E8=A8=B1=E5=8F=AF?= =?UTF-8?q?=E5=A4=96=E3=82=92=E5=8D=B3=E5=BA=A7=E3=81=AB=E6=8B=92=E5=90=A6?= =?UTF-8?q?=E3=81=97=E3=80=81=E3=83=9B=E3=82=B9=E3=83=88=20allowlist=20?= =?UTF-8?q?=E3=82=92=E5=AE=9A=E6=9C=9F=E5=86=8D=E8=A7=A3=E6=B1=BA=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コンテナ内のブラウザで GUI を開くと読み込みが完了せず、検証ができなかった。 原因は 2 つある。 1. 許可外が DROP なので「失敗」ではなく「ハング」になる。アプリは Monaco や Google ログインを待ち続け、ページ全体の読み込みが終わらない。REJECT に変えて 即座に失敗させる(出ていくデータは無いので遮断の強度は変わらない)。 2. ホスト名を起動時に 1 回だけ解決していたので、CDN の A レコード変更に追随 できない。実測で accounts.google.com は追加から数十分で切れていた。期限付きで 入れて背景ループが定期的に引き直す。 あわせて製品がランタイムで必要とする cdn.jsdelivr.net(Monaco)と accounts/apis.google.com(Google ログイン)、および運用でローカルに当てていた Debian リポジトリを EXTRA_HOSTS に入れる。解析用の GTM は開発に不要なので入れない。 Refs #1169 Co-Authored-By: Claude Opus 5 (1M context) --- .devcontainer/README.md | 32 ++++++++++ .devcontainer/init-firewall.sh | 113 ++++++++++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 46ae22e1f85..51fde6d3152 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -120,8 +120,40 @@ NaCl の隔離ガイドラインでは「非公開認証情報が到達する隔 | AWS | `ip-ranges.json` のうち **デプロイリージョン + us-east-1 + GLOBAL**、加えて SSO 系 (`oidc/portal.sso/sso..amazonaws.com`, SSO ポータル) | `cdk deploy` / `aws sso login` | | DNS | `/etc/resolv.conf` のリゾルバ + docker DNS (127.0.0.11) の 53 番のみ | 名前解決 (任意 DNS への exfil を遮断) | +- 製品がランタイムで取りに行く先も許可している: **`cdn.jsdelivr.net`** (Monaco Editor 本体。 + `src/lib/monaco-i18n-helper.js` が CDN から読む)、**`accounts.google.com` / + `apis.google.com`** (Google ログイン)。これが無いと **コンテナ内のブラウザで GUI を開いても + 読み込みが完了せず**、検証そのものができない。解析用の `www.googletagmanager.com` は + 開発に不要なので入れていない (拒否されても即失敗するだけで先に進む)。 - AWS のリージョンと SSO ポータルは **`infra/aws-sso.env`** から自動取得する (fork 時はそのファイルだけ書き換えれば firewall も追従)。 + +### 許可外は DROP ではなく REJECT (即座に失敗させる) + +許可外の宛先には **TCP RST / ICMP port-unreachable を返す**。外に出るデータは無いので遮断の +強度は DROP と同じだが、**待たされない**のが大きく違う: + +| | DROP (以前) | REJECT (現在) | +|---|---|---| +| 許可外への接続 | タイムアウトまで無応答 (実測 8 秒以上) | **即座に失敗** (実測 0.01 秒) | +| ブラウザで GUI を開いたとき | 外部依存を待ち続けて **ページ全体が読み込み完了しない** | その機能だけ失敗し、**エディタは使える** | +| 何が弾かれたか | 分かりにくい | すぐ分かる | + +ポリシー自体は `-P OUTPUT DROP` のまま残してある (REJECT ルールの構築に失敗しても閉じる +fail-closed の保険)。 + +### ホスト名の allowlist は定期的に再解決される + +`dig` で引いた IP を入れているだけなので、**CDN のように A レコードが変わる宛先は +起動時 1 回の解決では追随できない** (追加した直後は通るのに数十分後に切れる)。そのため: + +- ホスト名から入れた IP は **期限付き** (既定 1 時間) で ipset に入る。CIDR レンジ + (GitHub / AWS) は従来どおり無期限。 +- `init-firewall.sh` が **再解決ループを background で起動** し、既定 5 分ごとに + `EXTRA_HOSTS` と `firewall-allow.local` のホストを引き直して入れ直す。 + 消えた IP は期限切れで自然に落ちるので、許可が広がりっぱなしにならない。 +- 間隔と期限は env で変えられる: `FIREWALL_REFRESH_INTERVAL` (秒) / `FIREWALL_HOST_TTL` (秒)。 +- ループのログは `/tmp/init-firewall-refresh.log`、PID は `/tmp/init-firewall-refresh.pid`。 - IPv4 allowlist のみ。**IPv6 は egress を全遮断**する (allowlist を IPv4 で組むため、 IPv6 を開けると素通りの抜け道になる)。 - **fail-closed**: allowlist の構築に一部失敗しても、最後の DROP は必ず適用される。 diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index bb029818e0c..20e5ae5fa7e 100755 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -50,6 +50,16 @@ EXTRA_HOSTS=( objects.githubusercontent.com codeload.github.com nodejs.org + # Debian apt repositories (追加パッケージの取得用) + deb.debian.org + security.debian.org + # 製品がランタイムで取りに行く先。落とすとコンテナ内でのブラウザ検証が成立しない (#1169) + # cdn.jsdelivr.net … Monaco Editor 本体 (src/lib/monaco-i18n-helper.js) + # accounts/apis.google.com … Google ログイン (クラス管理 / Drive) + # www.googletagmanager.com は解析用で開発に不要なので入れない (REJECT で即失敗する)。 + cdn.jsdelivr.net + accounts.google.com + apis.google.com # AWS IAM Identity Center (SSO) endpoints for the deploy region "oidc.${AWS_REGION}.amazonaws.com" "portal.sso.${AWS_REGION}.amazonaws.com" @@ -57,10 +67,59 @@ EXTRA_HOSTS=( ) [[ -n "${SSO_PORTAL_HOST}" ]] && EXTRA_HOSTS+=("${SSO_PORTAL_HOST}") +# ホスト名から引いた IP の有効期限と、再解決の間隔 (#1169)。 +# CDN は A レコードが変わるので、起動時 1 回の解決では追随できない。 +HOST_ENTRY_TTL="${FIREWALL_HOST_TTL:-3600}" +REFRESH_INTERVAL="${FIREWALL_REFRESH_INTERVAL:-300}" +REFRESH_PIDFILE="/tmp/init-firewall-refresh.pid" +REFRESH_LOG="/tmp/init-firewall-refresh.log" + +# 個人用の追加 allowlist (gitignored)。1 行 1 ホスト or CIDR、'#' 以降はコメント。 +LOCAL_ALLOW="${ROOT}/.devcontainer/firewall-allow.local" +local_allow_lines() { + [[ -f "${LOCAL_ALLOW}" ]] || return 0 + local line + while read -r line; do + line="${line%%#*}"; line="$(printf '%s' "$line" | tr -d '[:space:]')" + [[ -z "$line" ]] || echo "$line" + done < "${LOCAL_ALLOW}" +} + +# 再解決の対象 = EXTRA_HOSTS + ローカル追加のうちホスト名のもの(CIDR は不変なので対象外)。 +dynamic_hosts() { + printf '%s\n' "${EXTRA_HOSTS[@]}" + local l + while read -r l; do [[ "$l" =~ ^[0-9.]+(/[0-9]+)?$ ]] || echo "$l"; done < <(local_allow_lines) +} + +# 期限切れ前に同じ IP を入れ直し、変わっていれば新しい IP を足す。ipset の timeout が +# あるので、消えた IP は放っておいても勝手に落ちる(許可が広がりっぱなしにならない)。 +refresh_once() { + local h ip n=0 + while read -r h; do + [[ -n "$h" ]] || continue + while read -r ip; do + [[ "$ip" =~ ^[0-9.]+$ ]] || continue + ipset add -exist allowed-dst "$ip" timeout "${HOST_ENTRY_TTL}" 2>/dev/null && n=$((n + 1)) + done < <(dig +short A "$h" 2>/dev/null) + done < <(dynamic_hosts) + echo "$n" +} + if [[ "$(id -u)" -ne 0 ]]; then warn "must run as root (needs NET_ADMIN/NET_RAW)"; exit 1; fi missing=(); for c in iptables ipset dig curl jq; do command -v "$c" >/dev/null 2>&1 || missing+=("$c"); done if [[ ${#missing[@]} -gt 0 ]]; then warn "missing tools: ${missing[*]} — firewall NOT applied"; exit 1; fi +# 再解決ループ(自分自身をバックグラウンドで起動する)。ルールは触らず ipset だけ更新する。 +if [[ "${1:-}" == "--refresh-loop" ]]; then + log "refresh loop started (interval=${REFRESH_INTERVAL}s ttl=${HOST_ENTRY_TTL}s)" + while true; do + sleep "${REFRESH_INTERVAL}" + ipset list -n allowed-dst >/dev/null 2>&1 || { log "allowed-dst が無くなったので再解決ループを終了"; exit 0; } + refresh_once >/dev/null + done +fi + log "starting (AWS region: ${AWS_REGION}${SSO_PORTAL_HOST:+, SSO portal: ${SSO_PORTAL_HOST}})" # --- reset ------------------------------------------------------------------------ @@ -75,12 +134,20 @@ ipset destroy allowed-dst 2>/dev/null || true # Collect all CIDRs/IPs into a restore file and load them in a single ipset call — # far faster than per-entry `ipset add` for the thousands of AWS prefixes. SET_FILE="$(mktemp)"; trap 'rm -f "${SET_FILE}"' EXIT -echo "create allowed-dst hash:net family inet hashsize 4096 maxelem 262144" > "${SET_FILE}" +# `timeout 0` を付けて作ると「既定は無期限・エントリ単位で期限を付けられる」set になる。 +# CIDR レンジ (GitHub / AWS) は無期限、ホスト名から引いた IP だけ期限付きで入れる (#1169)。 +echo "create allowed-dst hash:net family inet hashsize 4096 maxelem 262144 timeout 0" > "${SET_FILE}" emit_cidr() { [[ "$1" =~ ^[0-9.]+(/[0-9]+)?$ ]] && echo "add allowed-dst $1" >> "${SET_FILE}"; } +# ホスト由来の IP は HOST_ENTRY_TTL で失効させ、後述の再解決ループで入れ直す。 +# 一度きりの解決だと CDN (Google / Fastly 等) の A レコード変更に追随できず、 +# 「追加した直後は通るのに数十分後に切れる」状態になるため (#1169)。 emit_host() { local h="$1" ip f=0 - while read -r ip; do [[ "$ip" =~ ^[0-9.]+$ ]] || continue; emit_cidr "$ip"; f=1; done \ - < <(dig +short A "$h" 2>/dev/null) + while read -r ip; do + [[ "$ip" =~ ^[0-9.]+$ ]] || continue + echo "add allowed-dst $ip timeout ${HOST_ENTRY_TTL}" >> "${SET_FILE}" + f=1 + done < <(dig +short A "$h" 2>/dev/null) [[ $f -eq 1 ]] && log "+ $h" || warn "could not resolve $h" } @@ -111,13 +178,9 @@ fi for h in "${EXTRA_HOSTS[@]}"; do emit_host "$h"; done # Per-user additions (gitignored): one host or CIDR per line, '#' starts a comment. -LOCAL_ALLOW="${ROOT}/.devcontainer/firewall-allow.local" -if [[ -f "${LOCAL_ALLOW}" ]]; then - while read -r line; do - line="${line%%#*}"; line="$(printf '%s' "$line" | tr -d '[:space:]')"; [[ -z "$line" ]] && continue - if [[ "$line" =~ ^[0-9.]+(/[0-9]+)?$ ]]; then emit_cidr "$line"; log "+ (local) $line"; else emit_host "$line"; fi - done < "${LOCAL_ALLOW}" -fi +while read -r line; do + if [[ "$line" =~ ^[0-9.]+(/[0-9]+)?$ ]]; then emit_cidr "$line"; log "+ (local) $line"; else emit_host "$line"; fi +done < <(local_allow_lines) ipset restore -! < "${SET_FILE}" || warn "ipset restore reported errors" @@ -139,6 +202,17 @@ done log "DNS resolvers allowed: $resolvers" iptables -A OUTPUT -m set --match-set allowed-dst dst -j ACCEPT + +# 許可外は「黙って捨てる」のではなく「即座に拒否を返す」(#1169)。 +# 出ていくデータは無いので遮断の強度は同じだが、DROP だとアプリはタイムアウトまで待つ。 +# ブラウザ検証では外部依存 (Monaco / Google / 解析タグ) を待ち続けてページ全体の読み込みが +# 終わらなくなるため、即エラーにして「その機能だけ壊れる」状態に留める。 +iptables -A OUTPUT -p tcp -j REJECT --reject-with tcp-reset 2>/dev/null \ + || warn "could not add TCP REJECT rule (falling back to DROP policy)" +iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable 2>/dev/null \ + || warn "could not add ICMP REJECT rule (falling back to DROP policy)" + +# ポリシー自体は DROP のまま(上の REJECT ルール構築が失敗しても閉じる fail-closed の保険)。 iptables -P INPUT DROP; iptables -P FORWARD DROP; iptables -P OUTPUT DROP # --- IPv6: block entirely (allowlist is IPv4-only) -------------------------------- @@ -156,9 +230,28 @@ fi log "applied. allowlist entries: $(ipset list allowed-dst 2>/dev/null | grep -cE '^[0-9]+\.')" +# --- 再解決ループを起動 ------------------------------------------------------------- +# CDN の A レコード変更に追随する(起動時 1 回の解決だけだと数十分で切れる)。 +if [[ -f "${REFRESH_PIDFILE}" ]] && kill -0 "$(cat "${REFRESH_PIDFILE}" 2>/dev/null)" 2>/dev/null; then + kill "$(cat "${REFRESH_PIDFILE}")" 2>/dev/null || true +fi +nohup "$0" --refresh-loop >> "${REFRESH_LOG}" 2>&1 & +echo $! > "${REFRESH_PIDFILE}" +log "refresh loop: pid $(cat "${REFRESH_PIDFILE}") (every ${REFRESH_INTERVAL}s, log ${REFRESH_LOG})" + # --- verify ----------------------------------------------------------------------- curl -fsSL --max-time 8 https://api.github.com/zen >/dev/null 2>&1 \ && log "verify OK: github reachable" || warn "verify: github NOT reachable" + +# 許可外が「即座に失敗する」ことまで見る。時間がかかっているなら REJECT が効かず DROP に +# フォールバックしていて、ブラウザ検証がハングする状態に戻っている (#1169)。 +blocked_start="${SECONDS}" curl -fsSL --max-time 6 https://example.com >/dev/null 2>&1 \ && warn "verify FAIL: example.com reachable — allowlist too broad" || log "verify OK: example.com blocked" +blocked_elapsed=$(( SECONDS - blocked_start )) +if [[ "${blocked_elapsed}" -le 2 ]]; then + log "verify OK: 許可外は即座に失敗 (${blocked_elapsed}s)" +else + warn "verify: 許可外の失敗に ${blocked_elapsed}s かかった (REJECT が効いていない可能性)" +fi log "done" From fc25ff34eeb7a86b6703f7f4b46194d596232552 Mon Sep 17 00:00:00 2001 From: "smalruby3-editor-bot[bot]" <297607354+smalruby3-editor-bot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:09:30 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(devcontainer):=20=E5=86=8D=E8=A7=A3?= =?UTF-8?q?=E6=B1=BA=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E5=AD=A4=E5=85=90?= =?UTF-8?q?=E3=82=92=E6=8E=83=E9=99=A4=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pidfile だけを頼りにしていたので、pidfile を失うと孤児のループが残り、適用の たびに増えていく。パターンでも掃除する(適用側のプロセスは --refresh-loop を 持たないので自分は殺さない)。 Refs #1169 Co-Authored-By: Claude Opus 5 (1M context) --- .devcontainer/init-firewall.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index 20e5ae5fa7e..cb7cc9e78cd 100755 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -232,6 +232,10 @@ log "applied. allowlist entries: $(ipset list allowed-dst 2>/dev/null | grep -cE # --- 再解決ループを起動 ------------------------------------------------------------- # CDN の A レコード変更に追随する(起動時 1 回の解決だけだと数十分で切れる)。 +# pidfile だけを頼りにすると、pidfile を失ったときに孤児のループが残り続けて適用のたびに +# 増える。パターンでも掃除しておく(このプロセス自身は --refresh-loop を持たないので +# 自分を殺すことはない)。 +for _pid in $(pgrep -f "${0##*/} --refresh-loop" 2>/dev/null); do kill "${_pid}" 2>/dev/null || true; done if [[ -f "${REFRESH_PIDFILE}" ]] && kill -0 "$(cat "${REFRESH_PIDFILE}" 2>/dev/null)" 2>/dev/null; then kill "$(cat "${REFRESH_PIDFILE}")" 2>/dev/null || true fi