Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .devcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,40 @@ NaCl の隔離ガイドラインでは「非公開認証情報が到達する隔
| AWS | `ip-ranges.json` のうち **デプロイリージョン + us-east-1 + GLOBAL**、加えて SSO 系 (`oidc/portal.sso/sso.<region>.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 は必ず適用される。
Expand Down
117 changes: 107 additions & 10 deletions .devcontainer/init-firewall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,76 @@ 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"
"sso.${AWS_REGION}.amazonaws.com"
)
[[ -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 ------------------------------------------------------------------------
Expand All @@ -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"
}

Expand Down Expand Up @@ -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"

Expand All @@ -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) --------------------------------
Expand All @@ -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"
Loading