【Javascript】オブジェクト更新のデバッグ方法

経緯

担当しているTypescript/Javascriptのシステムで、予期せずトップの.envファイルが読み込まれ、どこかの処理でprocess.envに値がセットされている事象が発生しました。

原因特定のため、デバッガーや二分探索を使って.envファイルを読んでいる処理を特定しようとしていたのですが、グローバルオブジェクトである故になかなか見当がつけられませんでした。

どうするか

より効率いい方法がないか考えた結果、Proxyオブジェクトを使えばいいことに気がつきました
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy

// 一連処理のトップレベルで呼び出し
const envHandler = {
  set(target, key, value) {
    console.trace(`Environment variable ${key} was set with value: ${value}`)
    target[key] = value
    return true
  },
}

process.env = new Proxy(process.env, envHandler)

これで値がセットされた際に、traceログが出力されるようになり問題の特定につながりました。 process.envに限らず、特にグローバルオブジェクトや変数の謎更新のデバッグに使えそうなのでメモとして残しておきます

おまけ

本件はPrismaというライブラリがimportされると勝手に.envを読んでることが原因でした
https://www.prisma.io/docs/guides/development-environment/environment-variables#env-file-locations

Github actionsから、AWS WAFで設定したIP制限を突破する

概要

cloudfront等で、AWS WAFによってIPアクセス制限がかかっているリソースに対して、github actions hostedな環境からリクエストを送りたい場合があると思います。

github actionsが利用するグローバルIPの範囲は公開されていて、こちら全てをIP許可リストに追加することも可能ですが、保守面やセキュリティ観点としても推奨されるものではないと思います。

GitHub の IP アドレスはときどき変更されます。 IP アドレスによる許可はお勧めしません

GitHubのIPアドレスについて - GitHub Docs

そこで、実行中のgithub actions環境のIPを取得し、動的にIP許可リストに追加する方法を紹介します。

ちなみに、そもそもIPでなく専用のHTTPヘッダーを利用してWAFを突破するような方法も考えられますが、ここでは割愛します。

前提

以下はterraformの例ですが、WAF IPセットと許可ルールによるリソース構成になっている場合を想定します。

resource "aws_wafv2_ip_set" "my_project_github_actions" {
  provider           = aws.virginia
  name               = "github-actions"
  scope              = "CLOUDFRONT"
  ip_address_version = "IPV4"

  // 許可IPリストはgithub actions内の実行時に動的に追加/削除するためignoreする
  lifecycle {
    ignore_changes = [
      description,
      addresses,
    ]
  }
}

resource "aws_wafv2_web_acl" "my_project" {
  provider    = aws.virginia
  name        = "my-project-waf"
  scope       = "CLOUDFRONT"

 ~~中略~~

  rule {
    name     = "allow-github-actions-ip"
    priority = 1

    action {

      allow {
      }
    }

    statement {
      ip_set_reference_statement {
        arn = aws_wafv2_ip_set.my_project_github_actions.arn
      }
    }
  }
 ~~中略~~

結果

~~ 中略 ~~
jobs:
  sample:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      WAF_IPSET_SCOPE: {YOUR_WAF_IPSET_SCOPE}
      WAF_IPSET_REGION: {YOUR_WAF_IPSET_REGION}
      WAF_IPSET_ID: {YOUR_WAF_IPSET_ID}
      WAF_IPSET_NAME: {YOUR_WAF_IPSET_NAME}
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v3
      - name: authenticate to aws
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ${{ secrets.AWS_DEFAULT_REGION }}
          role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN }}
      - name: Get IP address
        id: get-ip
        run: echo "ip=$(curl -s https://checkip.amazonaws.com)" >> $GITHUB_OUTPUT
        shell: bash
      - name: Add IP address to ipset
        shell: bash
        run: |
          current_ip=${{ steps.get-ip.outputs.ip }}/32

          # このワークフローを並列で同時実行しても動くように、元から存在するIPをリストから消さないように更新する
          lock_token=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.LockToken')
          existing_ips=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.IPSet.Addresses[]')
          aws wafv2 update-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME --addresses $existing_ips $current_ip --lock-token $lock_token

          sleep 10  # ipsetの更新が反映されるまで待つ
      # ~~ IP制限しているリソースへのリクエスト付きの処理 ~~
      - name: Delete IP address from ipset
        if: always()
        shell: bash
        run: |
          ip_to_remove=${{ steps.get-ip.outputs.ip }}/32
          lock_token=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.LockToken')
          existing_ips=$(aws wafv2 get-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME | jq -r '.IPSet.Addresses[]')

          # このワークフローを並列で同時実行しても動くように、元から存在するIPをリストから消さないように更新する
          updated_ips=()
          for ip in $existing_ips; do
            if [ "$ip" != "$ip_to_remove" ]; then
              updated_ips+=("$ip")
            fi
          done

          if [ ${#updated_ips[@]} -eq 0 ]; then
              updated_ips_string='[]'
          else
              updated_ips_string=$(printf "%s " "${updated_ips[@]}")
          fi

          aws wafv2 update-ip-set --scope $WAF_IPSET_SCOPE --region $WAF_IPSET_REGION --id $WAF_IPSET_ID --name $WAF_IPSET_NAME --addresses $updated_ips_string --lock-token $lock_token

設定値は以下です

・YOUR_WAF_IPSET_SCOPE: CLOUDFRONT か REGIONAL
・YOUR_WAF_IPSET_REGION: ipsetの存在するリージョン(CLOUDFRONTの場合、globalではなくus-east-1を指定する必要がありました)
・YOUR_WAF_IPSET_ID: 添付図参照
・YOUR_WAF_IPSET_NAME: 添付図参照

ipset

解説

・処理の流れは大きく以下になってます

  1. 実行環境のIP取得
  2. 1で取得したIPをipsetにinsert
  3. IP制限環境へのリクエス
  4. 2で登録したIPをipsetから削除

aws wafv2 では、v1やセキュリティグループの仕様とは違って、ipsetのリストへそのまま任意のIPを挿入/削除することはできないようでした。 (おそらく、AWSの内部的なデータ整合性の観点だと思われます)

そのため、一度入れ替え更新のようなコードになっています。

This operation completely replaces the mutable specifications that you already have for the IP set with the ones that you provide to this call. To modify an IP set, do the following: 1. Retrieve it by calling GetIPSet 2. Update its settings as needed 3. Provide the complete IP set specification to this call

docs.aws.amazon.com

Nextjsで、app routerとpagesを併用するとページ遷移時に リロードされる

nextjs: 13.x

[2404追記]

公式ドキュメントで文書化済み nextjs.org

概要

既存のpages構成で稼働しているnextjsサーバーで、app routerを有効にして一部ページをappディレクトリに移行しました。
すると、pagesにある特定のリンクへのページ遷移/router.push()時に、SPAにも関わらず確定で強制リロード/page refresh/hard navigationのような挙動が発生しました。

頑張ってデバッグするとどうやら、app router機能のリリース辺りで以下のような修正が入っていたことに起因するようでした。

This adds a new client router filter for app paths and redirects so that we correctly hard navigate when a transition for one of this paths is encountered.
~~
The specific filter leveraged here is a bloom filter as we can tolerate some false matches (currently targeting an error rate of 2%) as they just trigger a hard navigation which should be acceptable and this also avoids needing to send an entire manifest with the related paths.

https://github.com/vercel/next.js/pull/46283
https://github.com/vercel/next.js/pull/49741

SPAにおいてもページ遷移時に、一定確率で静的もしくは動的なページであるとクライアント側で誤認定されて、ページリロードされるような挙動になるよう。。

対策

当該アプリケーションでは、ページ遷移時にstateが失われると壊れる設計になっており直ぐに対処する必要があったので、

next.config.tsでclientRouterFilter=falseにすることで一旦の暫定対応としました。

const nextConfig = {
   experimental: {
      clientRouterFilter: false,  // clientRouterFilterがページ遷移時に、hard navigationするかチェックしているみたい

~~省略~~

falseにすることのデメリットを完全に把握したわけではないので、よく動作確認すること推奨です。
また、issueも上がっていたので、恒久的な対応としてはnextjs側の対応を待つしかなさそうです。

https://github.com/vercel/next.js/issues/47486

safariでnew Date()すると、RangeError: Invalid date value が発生する本当の理由

概要

safariで、

new Date('2023-03-01 12:02:31'

のようなコードを書くと、Invalid Dateが発生することがあります

結論

ISO 8601(基本/拡張形式)に従ったフォーマットを指定しましょう

new Date('2023-03-01T12:02:31')  // 拡張形式に準拠して、日付と時刻の間に区切り文字Tを挿入

理由

ググると、safariでは日付の形式にハイフン使ってはいけないのでスラッシュ「/」を使いましょうみたいな解説の記事が多いですが、「ISO 8601に準拠していない形式では、動作が保証されていない」が正確だと思われます。

Date コンストラクターで日付文字列を解釈する際には、常に入力が ISO 8601 形式 (YYYY-MM-DDTHH:mm:ss.sssZ) であることを確認してください。他の形式で解釈した場合には、その挙動は実装によって定義されていて、すべてのブラウザーで動くとは限りません。

developer.mozilla.org

ISO 8601に準拠するべきと覚えておくと、同様に以下のようなフォーマットでエラーが発生した場合の理解や対処がスムーズになると思います。

new Date('2023-3-21T12:01:13') // 月がMM形式になっていない
new Date('2023-03-21T12.01.13') // 時刻がコロン「:」形式でない

日付の区切り文字がスラッシュ「/」でも多くのブラウザで動くのは、実装依存によるものでJavascriptとしての仕様によるものではないはずです。