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..cb7cc9e78cd 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,32 @@ fi log "applied. allowlist entries: $(ipset list allowed-dst 2>/dev/null | grep -cE '^[0-9]+\.')" +# --- 再解決ループを起動 ------------------------------------------------------------- +# 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 +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"