🔥

Cloudflare WARP クライアントのデバイスポスチャと自作のサービスプロバイダー API を連携し、MAC アドレスで認可する

に公開

WARP クライアントのデバイスポスチャ

Cloudflare は WARP クライアントのデバイスポスチャ(状態)を通信コントロールに利用できます。

デバイスポスチャを Cloudflare に伝える方法は 2 つあります。

今回のユースケース

WARP クライアントのデバイスポスチャに MAC アドレスを利用したいという要望がありました。

執筆時点(2025 年 9 月)では WARP クライアントチェックに MAC アドレス属性が含まれていません。

そこで、サードパーティ(カスタム統合 Custom device posture integration)の出番となります。

External API を自作します。
今回、こいつの仕事は
カスタム統合からの照合リクエスト(JSON with user and device identity)に自身に登録済みの MAC アドレスリストを参照して応答(JSON with 0-100 result)する、
になります。

ダッシュボードの設定箇所

設定 > WARP クライアント > デバイスポスチャ

それぞれの説明文

WARP クライアント チェック

これらのチェックは、WARP クライアントによってデバイス上で実行されます。

サードパーティのサービス プロバイダーの統合

サードパーティのサービス プロバイダーを統合して、追加のデバイス ポスチャ チェックを構成します。サポートされている統合のリストから選択するか、独自の統合を接続します

サービス プロバイダーのチェック

サードパーティ サービス プロバイダーがデバイス上で実行するチェックを構成します。次に、Access ポリシーまたは Gateway ポリシーにチェックを追加して、正常なデバイスを持つユーザーのみがリソースにアクセスできるようにします。

サードパーティとの統合については 2 ステップで設定をします

  • サードパーティのサービス プロバイダーの統合
    サードパーティと繋ぐ設定
  • サービス プロバイダーのチェック
    繋いだサードパーティから得られる情報(項目と値)に対するチェック条件を設定
    項目 A が 50 より小さい など)
    → これを Access や Gateway から参照し、認可の条件に利用

設計

フロー

下記のフローで実現します。

  1. カスタム統合は API エンドポイント External API に WARP デバイスとそのポスチャの一覧を送る(POST)
    API エンドポイントは サービストークンによる Access で認証が必須
  2. API エンドポイントはそのデバイス一覧から MAC アドレスを抽出し、登録済みリストと照合する
  3. 照合した結果を Cloudflare が期待する形(デバイス ID とスコア)で戻す
  4. Cloudflare ではその結果(スコア)をポリシーに照らし、動作に反映する

データフォーマット

1 でカスタム統合から API エンドポイントに渡される情報mac_address が含まれます。

フォーマット
{
  "devices": {
    [
      {
        "device_id": "9ece5fab-7398-488a-a575-e25a9a3dec07",
        "email": "jdoe@mycompany.com",
        "serial_number": "jdR44P3d",
        "mac_address": "74:1d:3e:23:e0:fe",
        "virtual_ipv4": "100.96.0.10",
        "hostname": "string",
      },
      {...},
      {...}
    ]
  }
}

3 で期待される応答に関しては、s2s_id は特に指定せず空にし、score のみ応答します。

フォーマット
{
  "result": {
    "9ece5fab-7398-488a-a575-e25a9a3dec07": {
      "s2s_id": "",
      "score": 10
    },
    "device_id2": {...},
    "device_id3": {...}
  }
}

スコアリング

MAC アドレスは Windows・macOS・Linux のみで、iOS・Android(chromeOS)は渡されない模様です。

For some devices, not all identifying information will apply, in which case the field will be blank.

なので、API エンドポイントでのスコア付けは下記の設計としてみます。

mac_address score
ポスチャに存在しない(モバイル) 50
ポスチャに存在し、登録リストにアリ 100
ポスチャに存在し、登録リストにナシ 0

期待動作

登録ナシの MAC アドレスの WARP デバイスを拒否するような設計にします。

  • サービスプロバイダーのチェック で定義の条件(X)
    カスタム統合のスコア= 0
  • Access や Gateway
    サービスプロバイダーのチェック条件(X)を Block

サーバーレス API

ここは Cloudflare です。You write code. We handle the rest.

WARP と同じ Cloudflare のアカウントサーバーレスMAC アドレス確認 API を作れます。

AWS や GCP などの外部リソース使わず、リージョンや CPU やメモリーとか気にせず、コードだけ書けばいいです。集中できます。 コードも生成 AI が助けてくれる時代です。

今回のような Cloudflare の標準機能で何かが足りない状況でも、アイデアひとつで可能性が広がるプラットフォームの上にいることを実感し、解決策を探せます。

なければ作ろう Workers

実装

こちらのステップに沿いますが、最初に API を作ります。

  1. API(Workers)の作成
  2. Access サービストークンの作成
  3. Access アプリケーションの作成
  4. WARP サードパーティサービスプロバイダの作成
  5. WARP サービスプロバイダのチェックの作成
  6. Access や Gateway への適用

1. API(Workers)の作成

Workers、ここから初められます。

カスタム統合の API エンドポイントについては Dev docsサンプルコードがあります。

ただ、理解のための参照にはいいのですが、実際に動かすには Hono フレームワークを使ったほうが楽なので、書き直します。

どんだけ楽できるか

たとえばサンプルコードのこのあたり

    const token = event.request.headers.get('Cf-Access-Jwt-Assertion')

    if (!token) {
      return new Response(
        JSON.stringify({ success: false, error: 'missing required cf authorization token' }),
        {
          status: 403,
          headers: { 'content-type': 'application/json' },
        },
      )
    }

    const jwks = jose.createRemoteJWKSet(new URL(`https://${TEAM_DOMAIN}/cdn-cgi/access/certs`))
    try {
      await jose.jwtVerify(token, jwks, {
        audience: `${POLICY_AUD}`
      })
    } catch (e){
      console.error(e)
      return new Response(
        JSON.stringify({ success: false, error: e.toString()}),
        {
          status: 403,
          headers: { 'content-type': 'application/json' },
        },
      )
    }

app.use('*', cloudflareAccess('my-access-team-name'));

の一撃だったりします。
ありがとう

CLI 例

コードを書く前のアプリケーション作成の CLI 例です。
Hono 関連もインストールしておきます。

$ npm create cloudflare@latest
:
 Create an application with Cloudflare Step 1 of 3

 In which directory do you want to create your application?
 dir ./posture-scoring-api-direct

 What would you like to start with?
 category Hello World example

 Which template would you like to use?
 type Worker only

 Which language do you want to use?
 lang TypeScript
:
 Deploy with Cloudflare Step 3 of 3

 Do you want to deploy your application?
 no deploy via `npm run deploy`

:

$ cd posture-scoring-api-direct/
$ npm install hono
$ npm install @hono/cloudflare-access
サンプルコード
  • MAC アドレスを入れた KV を作成
  • KV をバインド
  • アプリケーションにコードを追加
KV
$ npx wrangler kv namespace list | jq '.[]|select(.id=="<KV の ID - リモート>")'
{
  "id": "<KV の ID - リモート>",
  "title": "zt_mac",
  "supports_url_encoding": true
}

$ npx wrangler kv key list --binding KV_ZT_MAC --remote
[
  {
    "name": "mac"
  }
]

$ npx wrangler kv key get mac --binding KV_ZT_MAC --remote --text
["00:0c:29:09:5b:7a","02:00:17:01:35:aa"]
wrangler.jsonc
{
	"$schema": "node_modules/wrangler/config-schema.json",
	"name": "posture-scoring-api-direct",
	"main": "src/index.ts",
	"compatibility_date": "2025-09-13",
	"observability": {
		"enabled": true
	},
	"kv_namespaces": [
		{
          "binding": "KV_ZT_MAC",
		  "id": "<KV の ID - リモート>"
        }
    ]
}
src/index.ts
import { Hono } from 'hono';
import { cloudflareAccess } from '@hono/cloudflare-access'

type Bindings = {
  KV_ZT_MAC: KVNamespace;
};

const app = new Hono<{ Bindings: Bindings }>();

// Cloudflare Access のチーム名。一ラベル目だけ。cloudflareaccess.com 不要
const myTeam = 'your_team'; 

app.use('*', cloudflareAccess(myTeam));

// リクエストを処理し、mac_address に基づいてスコアを計算する関数
async function processDevices(c: { env: Bindings }, devices: any[]) {
  const kvKey = 'mac';
  const kvValue = await c.env.KV_ZT_MAC.get(kvKey);

  let macWhitelist: string[] = [];
  if (kvValue) {
    try {
      macWhitelist = JSON.parse(kvValue);
    } catch (e) {
      console.error('Failed to parse KV whitelist JSON:', e);
    }
  }

  // MAC アドレスそのものがない(モバイル)→ 50
  // MAC アドレス登録済み → 100
  // MAC アドレス未登録 → 0

  const result: Record<string, { s2s_id: string; score: number }> = {};
  for (const device of devices) {
    const { device_id, mac_address } = device;
    let score = 0;

    if (!mac_address) {
      score = 50;
    } else {
      const normalizedMac = mac_address.toLowerCase();
      if (macWhitelist.includes(normalizedMac)) {
        score = 100;
      } else {
        score = 0;
      }
    }

    result[device_id] = {
      s2s_id: "",
      score: score
    };
  }
  return { result };
}

// テスト GET で使うダミー JSON
const testData = {
  devices: [
    {
      device_id: '9ece5fab-7398-488a-a575-e25a9a3dec07',
      mac_address: '74:1d:3e:23:e0:fe', // ホワイトリストに存在しない MAC
    },
    {
      device_id: '12345678-abcd-efgh-ijkl-mnopqrstuvwx',
      // mac_address: 'aa:bb:cc:dd:ee:ff', // mac_address がない
    },
    {
      device_id: '32345678-abcd-efgh-ijkl-mnopqrstuvwz',
      mac_address: '02:00:17:01:35:aa', // ホワイトリストに存在する MAC
    },
  ],
};

// テスト GET
// curl -H "CF-Access-Client-Id: $CLIENTID" -H "CF-Access-Client-Secret: $CLIENTSECRET" -L -v $URL
app.get('/', async (c) => {
  console.log('Received GET request');
  const devices = testData.devices;
  const results = await processDevices(c, devices);
  console.log('GET request processed with results:', results);
  return c.json(results);
});

// 本番 POST
app.post('/', async (c) => {
  console.log('Received POST request');
  const body = await c.req.json();
  const devices = body.devices;
  const results = await processDevices(c, devices);
  console.log('POST request processed with results:', results);
  return c.json(results);
});

export default app;

Workers は KV をバインディング。

KV には MAC アドレスを登録。

デプロイすると https://posture-scoring-api-direct.<your-worker-sub>.workers.dev/ のような API エンドポイント URL に接続可能になります。

2. Access サービストークンの作成

サービストークン(ID と秘密の KEY)を使って API エンドポイントを保護します。
カスタム統合からの接続時に必須項目となっているので、あるものを再利用するか、なければ作ります

3. Access アプリケーションの作成

1 で作った API エンドポイント(*.workers.dev)を Access アプリケーション(セルフホスト)に登録します。

2 で作った サービストークンを紐付けます。

一旦確認

サンプルコードでは GET リクエストを受けると擬似的なポスチャデータを渡すようにしています。
実際の POST を試す前に KV との照合ができるか確認しておきます。
これにはサービストークンの認証が通るかの確認も含みます。

確認例

$WORKERS_API_URL は API エンドポイントです。
$CLIENTID $CLIENTSECRETにサービストークンを使います。
期待される応答は下記のとおりです。

$ curl -H "CF-Access-Client-Id: $CLIENTID" -H "CF-Access-Client-Secret: $CLIENTSECRET" $WORKERS_API_URL -L -s| jq '.'

{
  "result": {
    "9ece5fab-7398-488a-a575-e25a9a3dec07": {
      "s2s_id": "",
      "score": 0
    },
    "12345678-abcd-efgh-ijkl-mnopqrstuvwx": {
      "s2s_id": "",
      "score": 50
    },
    "32345678-abcd-efgh-ijkl-mnopqrstuvwz": {
      "s2s_id": "",
      "score": 100
    }
  }
}

4. WARP サードパーティサービスプロバイダの作成

カスタム統合を作成します。

  • アクセス クライアント IDクライアント シークレットにアクセスする: 2 で作ったサービストークン
  • REST API URL: API エンドポイント
  • ポーリング間隔: カスタム統合が API エンドポイントにポスチャを送る間隔
  • テスト を押し、エラーが無くなるまで、調整
    エラーの場合、「応答が期待とおりの JSON ではない」など、右上に警告文が出てきます。

成功すると、登録されます。

こっちの テスト を押すと、下記のような表示になります(成功時)。

この状態で、うまく行っていれば

API エンドポイントが実際に POST を受け付け、それに応じたスコアを返していることがわかります。
4 で設定したポーリング間隔ごとに POST が行われます。

wrangler tail
# API が受信したデータ

 (log) Device lists from CLoudflare: [
  {
    device_id: '9f18c5e7-9113-11f0-97eb-9e90d2d719a7',
    email: '*',
    serial_number: '*',
    mac_address: '00:0c:29:09:5b:7a',
    virtual_ipv4: '*',
    hostname: '*'
  },
  {
    device_id: 'd9c71bb2-9113-11f0-97eb-9e90d2d719a7',
    email: '*',
    serial_number: '*',
    virtual_ipv4: '*',
    hostname: '*'
  },
  {
    device_id: 'b62f2944-9113-11f0-97eb-9e90d2d719a7',
    email: '*',
    serial_number: '*',
    virtual_ipv4: '*',
    hostname: '*'
  },
  {
    device_id: '415efef5-9113-11f0-a927-563d1759b800',
    email: '*',
    mac_address: '02:00:17:01:35:aa',
    virtual_ipv4: '*',
    hostname: '*'
  }
]

# API が返信したデータ

  (log) POST request processed with results: {
  result: {
    '9f18c5e7-9113-11f0-97eb-9e90d2d719a7': { s2s_id: '', score: 100 },
    'd9c71bb2-9113-11f0-97eb-9e90d2d719a7': { s2s_id: '', score: 50 },
    'b62f2944-9113-11f0-97eb-9e90d2d719a7': { s2s_id: '', score: 50 },
    '415efef5-9113-11f0-a927-563d1759b800': { s2s_id: '', score: 100 }
  }
}

5. WARP サービスプロバイダのチェックの作成

カスタム統合の結果を使ったチェック条件を新規に作成します。

カスタム統合が出てくるので、選択します。

登録のない MAC アドレスデバイス ID のスコアを 0 にしているので、それをあぶり出す条件にし、「未登録のMACアドレス」と名前をつけます。

Access や Gateway から利用できるカスタムチェックができました。

6. Access や Gateway への適用

Access

Access ポリシーの作成

Access > ポリシー でカスタムチェック セレクター: Custom Service to Service を参照するルールを作成します。

  • アクション: Block
  • セレクター: Custom Service to Service
  • 値: 5 で作成したカスタムチェック「未登録のMACアドレス」

このポリシーは Access アプリケーションで利用できます。

Access アプリケーションへのポリシー適用

たとえば WARP のデバイス登録 WARP Login App (https://<your-team>.cloudflareaccess.com/warp) も Access アプリケーションの一つのなので、適用可能です。

設定 > WARP クライアント > デバイスの登録 で上記のポリシーを追加し、一番上に配置します。

登録がない MAC アドレスでの登録が拒否されました。

Access ログ

要求はデバイスポスチャーチェックの失敗により拒否されました。ポリシー「デバイスポスチャー - 外部 - MACアドレス」では、統合UID「5a9e723f-a7c5-486b-b8f9-1d61f51ed42b」によるデバイスポスチャーチェックが要求されていますが、これが満たされていません。このチェックはデバイスのMACアドレスを確認している可能性があります。

Gateway

Gateway の Network・HTTP ポリシーでもカスタムチェックが利用可能です。

  • アクション: Block
  • セレクター: Passed Device Posture Checks
  • 値: 5 で作成したカスタムチェック「未登録のMACアドレス」

Gateway を通過する通信に対して、(その通信自体には含まれない)送信元 WARP デバイスの MAC アドレスを参照し、未登録のものをブロックすることができるようになります。

Gateway ログ

ファイアウォールポリシー「外部デバイスの状態 - 信頼できないMACアドレス」(ID: 02c295eb-3e97-4d24-a487 -c525312f2fa6) によってブロックされました。これは、デバイスID 『415efef5-9113-11f0-a927-563d1759b800』 を持つユーザー 『*』 のデバイス状態チェックが失敗したためです。ポリシーでは、ID 『5a9e723f-a7c5-486b-b8f9-1d61f51ed42b』 のデバイス状態チェックが成功していることが要求されていましたが、成功しませんでした。さらに、ポリシーはHTTPリクエストのホストが 『example.com』 であることにも一致しました。ブロックの原因となった特定のドメイン分類はありません。ポリシーはブロックアクションを決定するためにカテゴリを使用していないためです。

ログ

Posture ログ

Posture log でそれぞれの WARP クライアントについて MAC アドレスの登録状況が確認できます。


条件を満たしている失敗 になっています。
これは、すべてのデバイスが スコア 0 を満たしていない = 登録済み MAC アドレスかモバイルということになります。

MAC アドレスのリストを変更すると、デバイスポスチャの判断が変わります。
登録リストから一つ削除します。

# 初期(2つ)

$ npx wrangler kv key get mac --binding KV_ZT_MAC --remote --text
["00:0c:29:09:5b:7a","02:00:17:01:35:aa"]

# 1 つに差し替え "02:00:17:01:35:aa" を削除

$ npx wrangler kv key put mac '["00:0c:29:09:5b:7a"]' --binding KV_ZT_MAC --remote
$ npx wrangler kv key get mac --binding KV_ZT_MAC --remote --text
["00:0c:29:09:5b:7a"]

登録から外したデバイスは変更後のポーリング(5 分間隔)で 失敗成功 に変わります。

最新の状態では 条件を満たしている すなわち、スコアが 0。
未登録のMACアドレス としての扱われることになります。

補足情報

このカスタム統合、現在アクティブな WARP デバイスだけでなく、リボーク状態や非アクティブのものもリストに入れて POST していました。
Posture ログでノイズになりえます。
それらを消し、アクティブなデバイスだけポスチャを確認する場合の処理を書いておきます。

アクティブ確認 devices/physical-devices

マイチーム > デバイス

devices/physical-devices のクエリーパラメータ
active_registrations で出力をコントロールします。
only(Active のみ) exclude(非 Active のみ) include(すべて)

# Active なデバイス
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/devices/physical-devices?active_registrations=only&per_page=100" \
-H "Authorization: Bearer $TOKEN" -s | jq -r '.result[].id'
d9c71bb2-9113-11f0-97eb-9e90d2d719a7
b62f2944-9113-11f0-97eb-9e90d2d719a7
bc2ca92e-8aba-4dcd-bc42-d75c8340f51c
415efef5-9113-11f0-a927-563d1759b800

# 非 Active
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/devices/physical-devices?active_registrations=exclude&per_page=100" \
-H "Authorization: Bearer $TOKEN" -s | jq -r '.result[].id'
0df6bcbb-9103-11f0-aa2c-664340b4581f
6f8613b2-8457-11f0-9e1e-beae97f6aa7b
27f8c617-54de-11f0-b567-3e98fab99122
1bf3ce30-a4a9-11ef-b774-76cc9abd01f6
e67025dd-9271-11ef-9944-7a89ec764239
1de28253-1afe-11ef-a036-229437538f2d
cfc760b8-127f-11ef-852f-06a9e3552d1f
3f4b0056-b08c-11ee-82d1-663284465895
e087e76a-b089-11ee-b112-022879bd6ec9
e48cfe98-b07a-11ee-ab91-c6fc777a2e0b
3225aecd-b072-11ee-87c6-329cd20b0c94
3ee51d3b-b070-11ee-af98-ea68dc40e030
83a0b505-24c9-11ee-a7d6-ee33ecdf48de
e6468aea-1ef3-11ee-8b2e-aa5df27ed78b
b6515d44-ca17-11ed-a952-facc7dfb7f20
4af36024-d8d7-11ec-9a82-4ac01897ed85
82dd1033-d876-11eb-8d62-26b299a3e158
c4eb8cd5-23ef-11eb-9e60-ae164d6fd35a
3c3c39b5-1512-11eb-aad7-ba669bcb6440
リボーク確認 devices/registrations

マイチーム > ユーザー > 指定のユーザー > デバイス

devices/registrations のクエリーパラメータ
status で出力をコントロールします。
active(Active のみ) revoked(リボーク状態) all(すべて)

curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/devices/registrations?status=active&per_page=100" \
-H "Authorization: Bearer $TOKEN" -s | jq -r '.result[].id'
d9c71bb2-9113-11f0-97eb-9e90d2d719a7
b62f2944-9113-11f0-97eb-9e90d2d719a7
9f18c5e7-9113-11f0-97eb-9e90d2d719a7
415efef5-9113-11f0-a927-563d1759b800

# Revoked

curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/devices/registrations?status=revoked&per_page=100" \
-H "Authorization: Bearer $TOKEN" -s | jq -r '.result[].id'
bd200a21-78ae-11f0-80cf-5ae3c3b34e36
bf46d388-78a7-11f0-98eb-964d1f9f73f8
cae4bfd5-7789-11f0-a2a6-1e6e627733fa
e5763c56-7660-11f0-86d9-2a85b01d5afe
af6eaacd-743b-11f0-bf1f-4e6fbd2ebac7
81602fb1-7409-11f0-8c35-c643f8ddc3e5

いずれも DELETE オプションで device id を指定することで削除できます。
不要なデバイスを削除することで、余計なログを減らすことができます。
physical-devices
registrations

一括削除のサンプルテスト用 !プロダクションでは利用するな!
cursor=""

# Function to delete devices or registrations based on the endpoint
delete_items() {
  local endpoint=$1
  local id_field=$2
  local delete_endpoint=$3

   echo $endpoint
   echo $delete_endpoint

  while :; do
    # Check if the endpoint already contains a query string
      response=$(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/$endpoint&per_page=100&cursor=$cursor" \
        -H "Authorization: Bearer $TOKEN")

     echo $response

    # Check if the response contains valid data before attempting to delete
    if [[ "$(echo "$response" | jq -r '.result')" == "null" || "$(echo "$response" | jq -r '.result | length')" -eq 0 ]]; then
      echo "No more items to delete or no items in this page. Continuing to next page."
    else
      # Loop through each item ID and send a DELETE request
      for d in $(echo "$response" | jq -r ".result[].$id_field"); do
        echo "Deleting $delete_endpoint with ID: $d"
        curl -s -X DELETE "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/$delete_endpoint/$d" \
          -H "Authorization: Bearer $TOKEN"
        sleep 1
      done
    fi

    # Get the cursor for the next page, or break if there's no cursor
    cursor=$(echo "$response" | jq -r '.result_info.cursor')
    if [[ "$cursor" == "null" || -z "$cursor" ]]; then
      echo "No more pages to fetch. Ending process."
      break
    fi
  done
}

# Delete physical devices
delete_items "devices/physical-devices?active_registrations=exclude" "id" "devices/physical-devices"

# Reset cursor for next batch
cursor=""

# Delete revoked registrations
delete_items "devices/registrations?status=revoked" "id" "devices/registrations"

以上、WARP クライアントのデバイスポスチャ、カスタム統合の評価でした。

Discussion