PowerShell で .bashrc 的なことがしたい

PowerShell にも .bashrc 的なファイルがあって、それを編集すればシェルを起動するときに任意の設定を読み込める。

$profile 変数にプロファイルファイルのパスが入っているので、これを編集する。なお、存在しない場合があるので、その場合は $profile のパスにファイルを作成する。

# 存在確認
Test-Path $profile

# なければ作る
New-Item -path $profile -type file -force

# 編集
code $profile

参考

WSL2 の localhost forwarding を代替する PowerShell Script を作った

以前、自分の環境では localhost forwarding で頻繁にハングすることがあるので、netsh interface portproxy add コマンドを使って手動で port forwarding するようにした。

mrk21.hatenablog.com

別に都度このコマンドを叩いてもいいのだが、WSL2 が listen しているポートを自動で port forwarding したいと思い、PowerShell Script の勉強がてら書いてみた。

使い方は、管理者権限で起動したターミナルで、 wsl-forwarding.ps1 を実行する。このスクリプト実行中は、WSL2側で 0.0.0.0 に listen すると、自動的にWindows側に port forwarding する。

f:id:mrk21:20220205143845g:plain

また、Windows側の port forwarding するIPアドレス0.0.0.0 としているので、外部からWSL2にアクセスできる。 WSL2 の localhost forwarding と同様にループバックアドレスからのみWSL2にアクセスできるようにしたい場合は $HostIP$HostIP = [IPAddress]"127.0.0.1" とする。

Terser で minify するときに ascii_only オプションを true にしないと Unicode Escape Sequence が展開されてしまう

Webpack + Babel + core-js な環境でビルドしたJavaScriptPerlサーバーで配信するプロジェクトで、あるときPerlサーバーで配信しようとしたときに Internal Server Error がでてしまった。

ログを確認すると、Wide character in subroutine entry at /path/to/Compress/Zlib.pm というエラーがでていた。どうやら、配信しようとしていたJavaScriptUnicodeが含まれていたため、Perlの内部文字列のutf8フラグが立ってしまい、そのままgzip圧縮をする Compress::Zlib モジュールに渡ってしまったのが原因のようだ。そのため、$content = encode("UTF-8", $content); としてUTF-8バイト文字列に変換すればよいのだが、そもそも元のJavaScriptにはUnicodeは含まれていない。しかし、ビルドされたJavaScriptを確認すると、たしかにUnicodeが含まれている。

いろいろ調査したところ、どうやら Terser は ascii_only オプションが false の時(デフォルト)は、"\u2028" といった Unicode Escape Sequence を展開してしまうことがあるらしいということがわかった。そして、core-js によって挿入された whitespaces.js というモジュールには Unicode Escape Sequence が含まれており、Terser がこれを展開してしまったため、Perlで読み込まれたときにutf8フラグが立ってしまい問題が引き起こされたようだ。

そのため、webpack.config.js で以下のように Terser の ascii_only オプションを true とすることで問題を解決した。

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  ...
  optimization: {
    minimizer: [new TerserPlugin({
      terserOptions: {
        format: {
          ascii_only: true
        }
      }
    })]
  }
}

今回のような状況下でなくても、Unicode Escape Sequence が勝手に展開されるのはバグの温床になるので、ascii_only オプションは常に true としておいたほうがよさそうである。

なお、このときの環境は以下の通り。

  • Node.js: 16.13.2
  • Webpack: 5.51.1
  • Babel: 7.15.0
  • core-js: 3.16.3
  • terser: 5.7.1
  • terser-webpack-plugin: 5.1.4

参考

WSL2 の localhostForwarding 機能がうまくうごかない

WSL2 の localhostForwarding 機能を使うと、WSL2側で listen したポートを自動的にWindows側で port forwarding してくれるので、Windows側からは localhost でWSL2側で listen しているポートにアクセスすることができる。

しかし、自分の環境ではアクセスはできるが、頻繁にハングすることがあり困っていた。

そのため、WSL2の localhostForwarding 機能を無効にして、かわりに netsh interface portproxy add v4tov4 コマンドを使って手動で port forwarding することにした。

まず、WSL2側のIPアドレス(WSL2が使用しているHyper-V仮想スイッチに接続しているアダプタのIPアドレス)を調べる。これは通常はeth0であるので、ip route |grep 'eth0 proto'|cut -d ' ' -f9 で取得することができる。ここで、Windows側からは bash.exe 経由で実行できるので、bash -c "ip route |grep 'eth0 proto'|cut -d ' ' -f9" で取得できる。

次に netsh interface portproxy add v4tov4 listenaddress=<host ip> listenport=<port> connectaddress=<wsl ip> connectport=<port> コマンドを使うことにより port forwarding することができるので、さきほど取得した WSL2 の IP を使って port forwarding する。 PowerShell スクリプトにまとめると以下の通りとなる。

wsl-proxy.ps1:

$WSL2_IPV4=bash -c "ip route |grep 'eth0 proto'|cut -d ' ' -f9"
$HOST_IPV4="0.0.0.0"
$PORT=$Args[0]
netsh interface portproxy delete v4tov4 listenaddress=$HOST_IPV4 listenport=$PORT
netsh interface portproxy add v4tov4 listenaddress=$HOST_IPV4 listenport=$PORT connectaddress=$WSL2_IPV4 connectport=$PORT
netsh interface portproxy show v4tov4

このスクリプトを管理者権限で起動したターミナルから実行する。

> wsl-proxy.ps1 3000

これで、ブラウザ等から localhost:3000 にアクセスすると、WSL2の 3000 ポートに転送されてアクセスできるようになる。

なお、このときの環境は以下のとおりである。

参考

WSL2 上の Docker container で名前解決が異常に遅い

個人PCでは Windows で WSL2 と WSL2 backend な Docker Desktop を使って開発しているが、Docker container 上での名前解決が異常に遅い。 調査していると、どうも Hyper-V のネットワークまわりに問題がありそうだが、具体的にどこがおかしいのかはわからなかった。 とりあえず Docker が使用するDNSゾルバを以下のように 8.8.8.8 に変更したところ問題が解決した。

Docker daemon config:

{
  "builder": {
    "gc": {
      "defaultKeepStorage": "20GB",
      "enabled": true
    }
  },
  "dns": [
    "8.8.8.8"
  ],
  "experimental": false
}

f:id:mrk21:20220123182954p:plain
docker daemon config

変更後は Docker Desktop を再起動する。

ついでにWSL上でも名前解決が遅かったので、以下のようにしてDNSゾルバを 8.8.8.8 に変更して解決した。

/etc/wsl.conf:

[network]
generateResolvConf = false

/etc/resolv.conf:

nameserver 8.8.8.8

変更後はWindows側で wsl.exe --shutdown をして再起動する。

なお、このときの環境は以下のとおりである。

  • OS: Windows 11 Pro
    • バージョン: 21H2
    • ビルド番号: 22000.466
  • WSL:
    • WSLバージョン: 0.51.2.0
    • カーネル バージョン: 5.10.81.1
    • WSLgバージョン: 1.0.30
  • Linuxディストリビューション: Ubuntu 20.04.3 LTS
    • WSLバージョン: 2
  • Docker Desktop: 4.4.3 (73365)
    • Engine: 20.10.12
    • Compose: 1.29.2

X-Forwarded-For の正しい取り扱い方とCloudFrontを通したときのクライアントIPの取得方法

X-Forwarded-For ってなに?

プロキシやロードバランサーを通してサーバーにアクセスすると、サーバーから見たクライアントIPはプロキシのものになってしまいます。それを解決するために、プロキシで X-Forwarded-For HTTP Header にクライアントの Remote IP を記録して、サーバーではその値を見ることでクライアントIPを取得できるようになります。

f:id:mrk21:20200806213221p:plain

X-Forwarded-For からクライアントIPを取得する

X-Forwarded-For HTTP Header は以下のようなフォーマットになっています。

X-Forwarded-For: <client>, <proxy1>, ...

そのためクライアントIPを得るためには X-Forwarded-For の先頭のIPを参照すればよさそうです。

しかし、この方法には問題があります。というのもプロキシでは X-Forwarded-For が存在していたのなら末尾に接続元のIPを追加するので、悪意のあるクライアントが X-Forwarded-For に任意のIPを指定することによってアクセス元IPを偽装することが可能になります(IPスプーフィング)。たとえば、サーバー側で X-Forwarded-For の値を元にIP制限をかけていた場合に突破される可能性があります。

f:id:mrk21:20200806213245p:plain

X-Forwarded-For からクライアントIPをセキュアに取得する

ではどうするのでしょうか。たとえば Rails では信頼できるプロキシのIPを事前に登録しておき、X-Forwarded-For のIPリストからその信頼できるプロキシのIPを除去し、末尾のIPをクライアントIPとして採用するというものがあります。

たとえば、サーバー到達時点の X-Forwarded-For の値が以下のようなものであったとします。

X-Forwarded-For: <偽装されたIP> <クライアントの真のIP>, <プロキシ1のIP>, <プロキシ2のIP>

そして、信頼できるプロキシのIPリストには以下が登録されているとします。

信頼できるプロキシのIPリスト := [プロキシ1のIP, プロキシ2のIP]

このような条件下では次の動作をします。まず、X-Forwarded-For から信頼できるプロキシのIPリストに登録しているIPを除去します。

X-Forwarded-For: <偽装されたIP> <クライアントの真のIP>

そして、その末尾のIPを取得します。これはクライアントの真のIPとなるので、偽装されたIPを取得することなく、悪意のあるクライアントからの攻撃を防げます。

f:id:mrk21:20200806215512p:plain

Railsでの詳細

ActionDispatch::Request#remote_ip では以下のようにして、信頼できるIPアドレスX-Forwarded-For から取得します。

module ActionDispatch
  class RemoteIp
    ...
    def call(env)
      req = ActionDispatch::Request.new env
      req.remote_ip = GetIp.new(req, check_ip, proxies)
      @app.call(req.env)
    end
    ...
    class GetIp
      ...
      def calculate_ip
        # Set by the Rack web server, this is a single value.
        remote_addr = ips_from(@req.remote_addr).last

        # Could be a CSV list and/or repeated headers that were concatenated.
        client_ips    = ips_from(@req.client_ip).reverse
        forwarded_ips = ips_from(@req.x_forwarded_for).reverse

        # +Client-Ip+ and +X-Forwarded-For+ should not, generally, both be set.
        # If they are both set, it means that either:
        #
        # 1) This request passed through two proxies with incompatible IP header
        #    conventions.
        # 2) The client passed one of +Client-Ip+ or +X-Forwarded-For+
        #    (whichever the proxy servers weren't using) themselves.
        #
        # Either way, there is no way for us to determine which header is the
        # right one after the fact. Since we have no idea, if we are concerned
        # about IP spoofing we need to give up and explode. (If you're not
        # concerned about IP spoofing you can turn the +ip_spoofing_check+
        # option off.)
        should_check_ip = @check_ip && client_ips.last && forwarded_ips.last
        if should_check_ip && !forwarded_ips.include?(client_ips.last)
          # We don't know which came from the proxy, and which from the user
          raise IpSpoofAttackError, "IP spoofing attack?! " \
            "HTTP_CLIENT_IP=#{@req.client_ip.inspect} " \
            "HTTP_X_FORWARDED_FOR=#{@req.x_forwarded_for.inspect}"
        end

        # We assume these things about the IP headers:
        #
        #   - X-Forwarded-For will be a list of IPs, one per proxy, or blank
        #   - Client-Ip is propagated from the outermost proxy, or is blank
        #   - REMOTE_ADDR will be the IP that made the request to Rack
        ips = [forwarded_ips, client_ips, remote_addr].flatten.compact

        # If every single IP option is in the trusted list, just return REMOTE_ADDR
        filter_proxies(ips).first || remote_addr
      end
      ...
      def filter_proxies(ips) # :doc:
        ips.reject do |ip|
          @proxies.any? { |proxy| proxy === ip }
        end
      end
    end
    ...
  end
end

rails/remote_ip.rb at c81af6ae723ccfcd601032167d7b7f57c5449c33 · rails/rails

そのため、config.action_dispatch.trusted_proxies に前段にあるプロキシ/ロードバランサーのIPを設定します。

config/initializers/trusted_proxies.rb:

Rails.application.configure do
  # Proxy 1: 56.103.84.14
  # Proxy 2: 56.103.84.15
  config.action_dispatch.trusted_proxies = %w{
    56.103.84.14
    56.103.84.15
  }.map { |proxy| IPAddr.new(proxy) }
end

このようにすることで、X-Forwarded-For が以下のようになっていた場合にクライアントのIPが取得できます。

X-Forwarded-For: 103.10.21.3, 56.103.84.14            # Proxy 1、Proxy 2を経由した場合。103.22.34.5 がクライアントIP
X-Forwarded-For: 17.20.1.3, 103.10.21.3, 56.103.84.14 # Proxy 1、Proxy 2を経由した場合。17.20.1.3 は偽装されたIP
class ApplicationController < ActionController::Base
  before_action :check_ip

  def check_ip
    if request.remote_ip == '17.20.1.3'
      ...
    end
  end
end

CloudFrontを使用した場合に X-Forwarded-For からクライアントIPをセキュアに取得する

上記で述べた方法でクライアントIPをセキュアに取得できますが、 CloudFront - ALB - Server という構成の場合は少々やっかいです。というのも CloudFront は世界中に数十ものエッジサーバーがあり、かつIPが変わり得るので、上記で述べたように CloudFront のIPを事前に信頼できるプロキシに登録するのは困難であるからです。

しかし、AWSAWSの各種サービスの取り得るIPリストを取得できるAPI( https://ip-ranges.amazonaws.com/ip-ranges.json )が存在し、これをもとに信頼できるIPリストに登録できます。Rails で一番簡単なのは config/initializer などで、Rails起動時に https://ip-ranges.amazonaws.com/ip-ranges.json APIにアクセスし、信頼できるIPリストに登録するというものです。

config/initializers/trusted_proxies.rb:

Rails.application.configure do
  ip_ranges_res = Faraday.get('https://ip-ranges.amazonaws.com/ip-ranges.json')
  ip_ranges = JSON.parse(ip_ranges_res.body)
  cloudfront_ips = ip_ranges['prefixes'].select { |v| v['service'] == 'CLOUDFRONT' }.map { |v| IPAddr.new(v['ip_prefix']) } +
                   ip_ranges['ipv6_prefixes'].select { |v| v['service'] == 'CLOUDFRONT' }.map { |v| IPAddr.new(v['ipv6_prefix']) }

  config.action_dispatch.trusted_proxies = cloudfront_ips
end

f:id:mrk21:20200806223957p:plain

まとめ

X-Forwarded-For を使うことで、クライアント・サーバー間にプロキシやロードバランサが存在してもクライアントのIPを取得することができます。 しかし、適切に扱わないと脆弱性が発生するので注意する必要があります。

参考

PROXY protocol を使ってNLB配下のサーバーでクライアントの Remote IP を得る

背景

  1. goproxy を使って HTTP Proxy server を作った
  2. その Proxy server は AWS Fargate で動作していてNLBでロードバランシングされている
  3. NLBはターゲットタイプがインスタンス以外だとRemote IPがNLBのものになってしまう
  4. Proxy server でクライアントのRemote IPがわからず困る

どうするか

goproxy をEC2で動かして、NLBのターゲットタイプをインスタンスにする、というのが手っ取り早いのですが、どうやら PROXY protocol というものがあり、 それを使うとこの問題を解決できるようです。

PROXY protocol ってなに

要はHTTPにおける X-Forwarded-For ヘッダーに相当するもので、TCP/IPベースの任意のプロトコルでクライアントのRemote IPなどの情報を付与するものです。 PROXY Protocolにはバージョン1とバージョン2があり、前者はテキスト、後者はバイナリです。両者ともTCP(UDP)パケットのペイロードに挿入されます。

PROXY protocol v1

以下の形式のプレーンテキストです。

PROXY_STRING + single space + INET_PROTOCOL + single space + CLIENT_IP + single space + PROXY_IP + single space + CLIENT_PORT + single space + PROXY_PORT + "\r\n"

https://developers.cloudflare.com/spectrum/getting-started/proxy-protocol/

たとえば以下のようになります。

PROXY TCP4 192.0.2.0 192.0.2.255 42300 443\r\n
  • INET_PROTOCOL: TCP over IPv4
  • CLIENT_IP: 192.0.2.0
  • PROXY_IP: 192.0.2.255
  • CLIENT_PORT: 42300
  • PROXY_PORT: 443

PROXY protocol v2

バイナリで、プロトコルTCP over IPv4 の場合は以下のようなレイアウトになっています。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                  Proxy Protocol v2 Signature                  |
+                                                               +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version|Command|   AF  | Proto.|         Address Length        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                      IPv4 Source Address                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    IPv4 Destination Address                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |        Destination Port       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

https://developers.cloudflare.com/spectrum/getting-started/proxy-protocol/

たとえば以下のようになります。

00000000  0D 0A 0D 0A 00 0D 0A 51  55 49 54 0A 21 11 00 0C  |.......QUIT.!...|
00000010  C0 A8 64 C8 C0 A8 64 C9  D8 6F 1F 90              |..d...d..o..|
  • Proxy Protocol v2 Signature: 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A
  • Version: 2(0x2)
  • Command: PROXY(0x1)
  • AF: IPv4(0x1)
  • Proto.: TCP over IPv4(0x1)
  • Address Length: 12 byte(0x00, 0x0C)
  • IPv4 Source Address: 192.168.100.200(0xC0, 0xA8, 0x64, 0xC8)
  • IPv4 Destination Address: 192.168.100.201(0xC0, 0xA8, 0x64, 0xC9)
  • Source Port: 55407(0xD8, 0x6F)
  • Destination Port: 8080(0x1F, 0x90)

Go言語で PROXY protocol をパースする

Go言語の場合、pires/go-proxyproto というライブラリを使うのが楽です。

server.go:

package main

import (
    "log"
    "net"

    "github.com/pires/go-proxyproto"
)

func main() {
    addr := "127.0.0.1:8080"
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        log.Fatalf("couldn't listen to %q: %q\n", addr, err.Error())
    }

    ppListener := &proxyproto.Listener{Listener: listener}
    defer ppListener.Close()

    conn, _ := ppListener.Accept()
    defer conn.Close()

    if conn.LocalAddr() == nil {
        log.Fatal("couldn't retrieve local address")
    }
    log.Printf("local address: %q", conn.LocalAddr().String())

    if conn.RemoteAddr() == nil {
        log.Fatal("couldn't retrieve remote address")
    }
    log.Printf("remote address: %q", conn.RemoteAddr().String())
}

ppListener := &proxyproto.Listener{Listener: listener} で生の net.Listener をラップすることで PROXY protocol を解析できます。 ppListenernet.Listener 互換のインターフェイスを持っているため、後続のコードでは proxyproto を使っているかどうかを気にせず使用できます(conn.RemoteAddr() などが IPヘッダのものではなく、PROXY Protocol によって指定されたものになる)。

このサーバーに PROXY protocol のヘッダを付与して通信してみます。

client.go:

package main

import "net"

func main() {
    ppv2 := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A} // \r\n \r\n \0 \r\n QUIT \n
    ppv2 = append(ppv2, []byte{0x21}...)                                                   // Version 2, PROXY command
    ppv2 = append(ppv2, []byte{0x11}...)                                                   // AF_INET, SOCK_STREAM
    ppv2 = append(ppv2, []byte{0x00, 0x0C}...)                                             // Src/Dst addr/port length
    ppv2 = append(ppv2, []byte{0xC0, 0xA8, 0x64, 0xC8}...)                                 // Src addr: 192.168.100.200
    ppv2 = append(ppv2, []byte{0xC0, 0xA8, 0x64, 0xC9}...)                                 // Dst addr: 192.168.100.201
    ppv2 = append(ppv2, []byte{0xD8, 0x6F}...)                                             // Src port: 55407
    ppv2 = append(ppv2, []byte{0x1F, 0x90}...)                                             // Dst port: 8080

    addr := "127.0.0.1:8080"
    conn, err := net.Dial("tcp", addr)
    defer conn.Close()
    if err != nil {
        panic(err)
    }

    conn.Write(ppv2)
}

結果:

2020/07/31 15:40:19 local address: "192.168.100.201:8080"
2020/07/31 15:40:19 remote address: "192.168.100.200:55407"

PROXY protocol で指定したアドレスになっていることが確認できました。

NLBで PROXY protocol を有効にする

NLBではターゲットグループで以下の設定を有効にすることにより、NLB配下のサーバーに PROXY protocol を付与することができます。

f:id:mrk21:20200801195225p:plain f:id:mrk21:20200801195300p:plain

注意点としては、ヘルスチェックするときも PROXY protocol のヘッダを付与するので、ヘルスチェックで指定したエンドポイントでも PROXY protocol を考慮する必要があります。

まとめ

PROXY protocol を利用することによって、TCP/IP上の任意のプロトコルでクライアントのIPを取得することができます。 しかし、PROXY protocol を有効にしたサーバーをインターネット上に公開すると外部からアクセス元が改竄できてしまうので注意が必要です。 また、Go言語で PROXY protocol を使うサンプルは https://github.com/mrk21/sandbox/tree/master/go-go-proxyproto にあります。

参考