設定發布商提供的 IP

瀏覽器隱私權 Proxy 和 IP 保護設定,可能會遮蓋向 Google Ad Manager 傳送第三方廣告請求的用戶端 IP 位址。遮蓋 IP 位址可能會降低指定地理區域的準確度和廣告關聯性。由於網路伺服器或內容傳遞網路 (CDN) 是在第一方情境中與使用者互動,因此伺服器會收到使用者未遮蓋的 IP 位址。

發布商提供的 IP 功能可讓您在伺服器上擷取使用者未遮蓋的 IPv4 或 IPv6 位址,使用混合式公開金鑰加密 (HPKE) 加密位址,並將加密酬載傳遞至用戶端上的 Google 發布商廣告代碼 (GPT)。

本頁說明如何將發布商提供的 IP 與 GPT 整合。

實作伺服器端工作流程

為避免使用者的明文 IP 位址在瀏覽器中向第三方指令碼公開,請在網路伺服器或 CDN 邊緣工作站執行下列步驟:

  • 執行所有 IP 擷取。
  • 擷取公開金鑰。
  • 執行 HPKE 加密。

擷取用戶端 IP 位址

從網路伺服器或 CDN 上的傳入網頁要求中,擷取用戶端未遮蓋的 IPv4 或 IPv6 位址:

  • 直接連線:讀取傳入 HTTP 連線的遠端通訊端位址。
  • 位於 CDN、負載平衡器或反向 Proxy 後方:從信任的邊緣 Proxy 讀取用戶端 IP 標頭,例如 X-Forwarded-For 標頭或 CDN 專屬用戶端 IP 標頭。請只信任 Proxy 基礎架構新增或覆寫的轉送標頭。如果 X-Forwarded-For 標頭包含多個以半形逗號分隔的位址,請選取信任的邊緣 Proxy 新增的用戶端 IP。

Ad Manager 支援下列地址:

  • IPv4,例如 203.0.113.42。
  • IPv6,例如 2001:db8::8a2e:370:7334。

僅傳遞裸 IP 位址字串,不含通訊埠編號、括號、CIDR 前置字元或周圍空白字元。

擷取及快取公開金鑰

Google 會在下列端點,將有效的 HPKE 加密金鑰發布為 RFC 8037 JSON Web 金鑰集 (JWKS):

https://www.gstatic.com/real-ip/ip_enc_key.json

向這個端點傳送 GET 要求,會傳回 JSON 物件,其 keys 陣列包含單一有效金鑰物件。使用該物件 x 屬性中的金鑰,加密用戶端 IP 位址。

以下範例會擷取公開金鑰,並在成功時擷取 x 屬性,失敗時則擲回例外狀況:

Java

private static final String KEY_ENDPOINT_URL = "https://www.gstatic.com/real-ip/ip_enc_key.json";

/**
 * Fetches the active X25519 public key from Google's key endpoint.
 *
 * <p>In production, cache the returned public key in memory and refresh it at least once every 24
 * hours rather than fetching it on every incoming request.
 *
 * @return The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
 */
public static String fetchPublicKey() throws IOException, InterruptedException {
  return fetchPublicKey(KEY_ENDPOINT_URL);
}

public static String fetchPublicKey(String endpointUrl) throws IOException, InterruptedException {
  HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
  HttpRequest request =
      HttpRequest.newBuilder()
          .uri(URI.create(endpointUrl))
          .timeout(Duration.ofSeconds(5))
          .GET()
          .build();
  HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
  if (response.statusCode() < 200 || response.statusCode() >= 300) {
    throw new IOException("Failed to fetch public key, HTTP status: " + response.statusCode());
  }
  JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
  JsonArray keys = json.getAsJsonArray("keys");
  if (keys == null || keys.isEmpty()) {
    throw new IOException("No keys found in response from " + endpointUrl);
  }
  JsonObject keyObj = keys.get(0).getAsJsonObject();
  if (!keyObj.has("x")) {
    throw new IOException("Key payload missing 'x' property: " + keyObj);
  }
  return keyObj.get("x").getAsString();
}

Python

KEY_ENDPOINT_URL = "https://www.gstatic.com/real-ip/ip_enc_key.json"


def fetch_public_key(url: str = KEY_ENDPOINT_URL) -> str:
  """Fetches the active X25519 public key from Google's key endpoint.

  In production, cache the returned public key in memory and refresh it at
  least once every 24 hours rather than fetching it on every incoming request.

  Args:
    url: URL of the public key endpoint.

  Returns:
    The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
  """
  with urllib.request.urlopen(url, timeout=5) as response:
    data = json.loads(response.read().decode("utf-8"))
  keys = data.get("keys", [])
  if not keys:
    raise ValueError(f"No keys found in response from {url}")
  key_payload = keys[0].get("x")
  if not key_payload:
    raise ValueError(f"Key payload missing 'x' property: {keys[0]}")
  return key_payload


JavaScript

const KEY_ENDPOINT_URL = 'https://www.gstatic.com/real-ip/ip_enc_key.json';

/**
 * Fetches the active X25519 public key from Google's key endpoint.
 *
 * In production, cache the returned public key in memory and refresh it at
 * least once every 24 hours rather than fetching it on every incoming request.
 *
 * @param {string=} url URL of the public key endpoint.
 * @return {!Promise<string>} The unpadded Base64URL-encoded 32-byte X25519
 *     public key ('x').
 */
async function fetchPublicKey(url = KEY_ENDPOINT_URL) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(
      `Failed to fetch public key, HTTP status: ${response.status}`,
    );
  }
  const data = await response.json();
  if (!data.keys || data.keys.length === 0) {
    throw new Error(`No keys found in response from ${url}`);
  }
  const keyPayload = data.keys[0].x;
  if (!keyPayload) {
    throw new Error(
      `Key payload missing 'x' property: ${JSON.stringify(data.keys[0])}`,
    );
  }
  return keyPayload;
}

TypeScript

const KEY_ENDPOINT_URL = 'https://www.gstatic.com/real-ip/ip_enc_key.json';

interface JwkKey {
  kty?: string;
  crv?: string;
  x?: string;
  kid?: string;
}

interface KeyEndpointResponse {
  keys?: JwkKey[];
}

/**
 * Fetches the active X25519 public key from Google's key endpoint.
 *
 * In production, cache the returned public key in memory and refresh it at
 * least once every 24 hours rather than fetching it on every incoming request.
 *
 * @param url URL of the public key endpoint.
 * @return The unpadded Base64URL-encoded 32-byte X25519 public key ('x').
 */
export async function fetchPublicKey(
  url: string = KEY_ENDPOINT_URL,
): Promise<string> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(
      `Failed to fetch public key, HTTP status: ${response.status}`,
    );
  }
  const data = (await response.json()) as KeyEndpointResponse;
  if (!data.keys || data.keys.length === 0) {
    throw new Error(`No keys found in response from ${url}`);
  }
  const keyPayload = data.keys[0].x;
  if (!keyPayload) {
    throw new Error(
      `Key payload missing 'x' property: ${JSON.stringify(data.keys[0])}`,
    );
  }
  return keyPayload;
}

如果成功,回應會包含 keys 陣列,其中含有具有 x 屬性的金鑰物件:

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "X25519",
      "x": "7bws5TIHOUrcJNofEubW3DPW8zR9EtjFNLm_Grv98TA",
      "kid": "EZMLFw"
    }
  ]
}

下表說明 keys 陣列中索引鍵物件的屬性:

屬性 說明
crv 橢圓曲線 ID。一律X25519。
kid 未填補的 base64url 編碼金鑰 ID,會在 Google 輪替金鑰時變更。
kty 金鑰類型。一律為 OKP (八位元組金鑰組)。
x 未經填補、採用 base64url 編碼的 32 位元組原始 X25519 公開金鑰,用於加密。

將擷取的公開金鑰快取到伺服器的記憶體中,並至少每 24 小時重新整理一次金鑰,或根據 HTTP Cache-Control 回應標頭提前重新整理。請勿在每次收到網頁要求時擷取公開金鑰。Google 會保留輪替的私密金鑰。如果酬載是以過去 24 小時內快取的公開金鑰加密,則在金鑰輪替期間,酬載仍會持續解密,不會中斷。

加密 IP 位址

使用 HPKE (RFC 9180) 和解碼後的 32 位元組 X25519 公開金鑰 x (在擷取及快取公開金鑰中擷取),加密用戶端 IP 位址字串的 UTF-8 位元組表示法。

以下範例會加密 IP 位址字串:

Java

這個範例使用 Tink (com.google.crypto.tink:tink 1.16.0 以上版本):

static {
  try {
    HybridConfig.register();
  } catch (GeneralSecurityException e) {
    throw new ExceptionInInitializerError(e);
  }
}

/**
 * Encrypts an IP address using HPKE (DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCM).
 *
 * @param pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
 * @param ipStr Plaintext IPv4 or IPv6 address string (for example, "203.0.113.42").
 * @return Unpadded Base64URL-encoded payload (enc || ciphertext).
 */
@AccessesPartialKey
public static String encryptIp(String pubXB64Url, String ipStr) throws GeneralSecurityException {
  // 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  byte[] pubBytes = Base64.getUrlDecoder().decode(pubXB64Url);
  if (pubBytes.length != 32) {
    throw new IllegalArgumentException(
        "Public key must be exactly 32 bytes, got " + pubBytes.length);
  }

  // 2. Configure HPKE parameters (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  //    AES_256_GCM, NO_PREFIX).
  HpkeParameters parameters =
      HpkeParameters.builder()
          .setKemId(HpkeParameters.KemId.DHKEM_X25519_HKDF_SHA256)
          .setKdfId(HpkeParameters.KdfId.HKDF_SHA256)
          .setAeadId(HpkeParameters.AeadId.AES_256_GCM)
          .setVariant(HpkeParameters.Variant.NO_PREFIX)
          .build();

  HpkePublicKey publicKey =
      HpkePublicKey.create(parameters, Bytes.copyFrom(pubBytes), /* idRequirement= */ null);

  // 3. Encrypt the UTF-8 IP address bytes. With Variant.NO_PREFIX, Tink
  //    outputs raw (enc || ciphertext_with_tag).
  KeysetHandle handle =
      KeysetHandle.newBuilder()
          .addEntry(KeysetHandle.importKey(publicKey).withRandomId().makePrimary())
          .build();

  HybridEncrypt encryptor = handle.getPrimitive(RegistryConfiguration.get(), HybridEncrypt.class);
  byte[] payload =
      encryptor.encrypt(ipStr.getBytes(StandardCharsets.UTF_8), /* contextInfo= */ new byte[0]);

  // 4. Return unpadded Base64URL-encoded payload.
  return Base64.getUrlEncoder().withoutPadding().encodeToString(payload);
}

Python

本範例使用 pyhpke:

def encrypt_ip(pub_x_b64url: str, ip_str: str) -> str:
  """Encrypts an IP address using HPKE (RFC 9180).

  Args:
    pub_x_b64url: Base64URL-encoded 32-byte X25519 public key ('x').
    ip_str: Plaintext IPv4 or IPv6 address string (for example, "203.0.113.42").

  Returns:
    Unpadded Base64URL-encoded payload (enc || ciphertext).
  """
  # 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  pub_bytes = base64.urlsafe_b64decode(
      pub_x_b64url + "=" * (-len(pub_x_b64url) % 4)
  )
  if len(pub_bytes) != 32:
    raise ValueError(
        f"Public key must be exactly 32 bytes, got {len(pub_bytes)}"
    )

  # 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  #    AES256_GCM).
  suite = CipherSuite.new(
      KEMId.DHKEM_X25519_HKDF_SHA256, KDFId.HKDF_SHA256, AEADId.AES256_GCM
  )
  public_key = suite.kem.deserialize_public_key(pub_bytes)

  # 3. Create a fresh sender context per encryption (you may cache 'suite' and
  #    'public_key', but never reuse 'sender' across multiple seal() calls) and
  #    package (enc || ciphertext).
  enc, sender = suite.create_sender_context(public_key)
  ciphertext = sender.seal(ip_str.encode("utf-8"))

  # 4. Return unpadded Base64URL-encoded payload.
  return base64.urlsafe_b64encode(enc + ciphertext).decode("utf-8").rstrip("=")


JavaScript

本範例使用 @hpke/core:

/**
 * Encrypts an IP address using HPKE (RFC 9180).
 *
 * @param {string} pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
 * @param {string} ipStr Plaintext IPv4 or IPv6 address string (for example,
 *     "203.0.113.42").
 * @return {!Promise<string>} Unpadded Base64URL-encoded payload
 *     (enc || ciphertext).
 */
async function encryptIp(pubXB64Url, ipStr) {
  // 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  const pubBytes = Buffer.from(pubXB64Url, 'base64url');
  if (pubBytes.length !== 32) {
    throw new Error(
      `Public key must be exactly 32 bytes, got ${pubBytes.length}`,
    );
  }

  // 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  //    AES_256_GCM).
  const suite = new CipherSuite({
    kem: new DhkemX25519HkdfSha256(),
    kdf: new HkdfSha256(),
    aead: new Aes256Gcm(),
  });
  const rawPubKey = new Uint8Array(pubBytes).buffer;
  const publicKey = await suite.kem.importKey('raw', rawPubKey, true);

  // 3. Encrypt the UTF-8 IP address bytes and package (enc || ciphertext).
  const plaintext = new Uint8Array(Buffer.from(ipStr, 'utf8')).buffer;
  const {enc, ct} = await suite.seal(
    {recipientPublicKey: publicKey},
    plaintext,
  );

  // 4. Return unpadded Base64URL-encoded payload.
  return Buffer.concat([Buffer.from(enc), Buffer.from(ct)]).toString(
    'base64url',
  );
}

TypeScript

本範例使用 @hpke/core:

/**
 * Encrypts an IP address using HPKE (RFC 9180).
 *
 * @param pubXB64Url Base64URL-encoded 32-byte X25519 public key ('x').
 * @param ipStr Plaintext IPv4 or IPv6 address string (for example,
 *     "203.0.113.42").
 * @return Unpadded Base64URL-encoded payload (enc || ciphertext).
 */
export async function encryptIp(
  pubXB64Url: string,
  ipStr: string,
): Promise<string> {
  // 1. Decode the 32-byte X25519 public key 'x' from Base64URL.
  const pubBytes = Buffer.from(pubXB64Url, 'base64url');
  if (pubBytes.length !== 32) {
    throw new Error(
      `Public key must be exactly 32 bytes, got ${pubBytes.length}`,
    );
  }

  // 2. Configure HPKE cipher suite (DHKEM_X25519_HKDF_SHA256, HKDF_SHA256,
  //    AES_256_GCM).
  const suite = new CipherSuite({
    kem: new DhkemX25519HkdfSha256(),
    kdf: new HkdfSha256(),
    aead: new Aes256Gcm(),
  });
  const rawPubKey = new Uint8Array(pubBytes).buffer;
  const publicKey = await suite.kem.importKey('raw', rawPubKey, true);

  // 3. Encrypt the UTF-8 IP address bytes and package (enc || ciphertext).
  const plaintext = new Uint8Array(Buffer.from(ipStr, 'utf8')).buffer;
  const {enc, ct} = await suite.seal(
    {recipientPublicKey: publicKey},
    plaintext,
  );

  // 4. Return unpadded Base64URL-encoded payload.
  return Buffer.concat([Buffer.from(enc), Buffer.from(ct)]).toString(
    'base64url',
  );
}

使用下列加密套件和參數設定 HPKE 程式庫:

參數 值
金鑰封裝機制 (KEM) DHKEM(X25519, HKDF-SHA256) (0x0020)
金鑰衍生函式 (KDF) HKDF-SHA256 (0x0001)
支援關聯資料的驗證式加密 (AEAD) AES-256-GCM (0x0002)
模式 基本 (0x00)
應用程式資訊 (info) 空白 ("")
相關資料 (aad) 空白 ("")

將加密輸出內容格式化為 32 位元組封裝暫時公開金鑰 enc 和 AEAD 密文 (包括 16 位元組 GCM 驗證標記) 的原始串連,不含 Tink 或自訂標頭前置字元。將結果編碼為未填補的 base64url 字串。

使用新的臨時金鑰組,將每個酬載加密為獨立的一次性 HPKE 訊息。如果 HPKE 程式庫使用有狀態的傳送者情境,請為每次加密建立新的情境,而非在多則訊息中重複使用。為每個網頁要求加密用戶端 IP 位址,或為每個 IP 位址快取產生的 base64url 密文,並設定短暫的存留時間 (TTL)。

實作用戶端工作流程

伺服器產生加密的 base64url IP 酬載後,請先將酬載傳遞至用戶端的 GPT,再請求廣告。

將加密 IP 傳遞至 GPT

使用 PageSettingsConfig 的 ipAddress 屬性,將加密的 IP 字串傳遞至 googletag.setConfig 方法。

以下範例在啟用服務和顯示廣告版位前,將伺服器產生的加密 IP 酬載插入 googletag.setConfig 方法:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Publisher-provided IP example</title>
    <script src="https://securepubads.g.doubleclick.net/tag/js/gpt.js" crossorigin="anonymous" async></script>
    <script>
      window.googletag = window.googletag || {cmd: []};

      // Inject the base64url-encoded encrypted IP generated by your server.
      const encryptedClientIp = 'ENCRYPTED_IP_PAYLOAD';

      googletag.cmd.push(function() {
        // Configure the encrypted IP before enabling services.
        googletag.setConfig({
          ipAddress: encryptedClientIp,
        });

        googletag
            .defineSlot('/6355419/Travel/Europe/France/Paris', [300, 250], 'banner-ad')
            .addService(googletag.pubads());
        googletag.enableServices();
      });
    </script>
  </head>
  <body>
    <div id="banner-ad" style="width: 300px; height: 250px;"></div>
    <script>
      googletag.cmd.push(function() {
        googletag.display('banner-ad');
      });
    </script>
  </body>
</html>

將 ENCRYPTED_IP_PAYLOAD 替換為伺服器產生的未填補 base64url 編碼字串。

如要清除先前在網頁上設定的 IP 位址,請將 ipAddress 屬性設為 null 值:

googletag.setConfig({ ipAddress: null });

驗證整合設定

如要確認 GPT 是否傳送加密 IP 酬載,以及 Ad Manager 是否解密酬載,請使用瀏覽器的開發人員工具和 Google 發布商控制台:

瀏覽器開發人員工具

  1. 開啟瀏覽器的開發人員工具。
  2. 選取「網路」分頁標籤。
  3. 篩選傳送至 /gampad/ads 的廣告請求。
  4. 確認要求網址包含非空白的 ips 查詢參數。由於 GPT 會在傳送廣告請求前進一步編碼 ipAddress 值,因此 ips 參數不會與加密字串完全相符。

Google 發佈商控制台

  1. 如要開啟控制台,請按照 Google 發布商控制台指南中的步驟操作。
  2. 選取「網頁要求」分頁標籤。
  3. 確認沒有顯示 ipAddress 警告。如果酬載有問題,控制台會記錄下列其中一則警告:

    • 如果傳遞至 ipAddress 屬性的值不是有效的 base64 或 base64url 字串,且長度介於 67 到 124 個字元之間,GPT 會忽略該值,從廣告請求中省略 ips 參數,並記錄下列警告:

      Invalid value encountered when calling: googletag.setConfig: {"ipAddress":"..."}. (string) ... was found at PageSettingsConfig.ipAddress. Invalid IP address format.
      
    • 如果 Ad Manager 無法解密提供的 IP 酬載,或解密後的明文不是有效的 IPv4 或 IPv6 位址,控制台會記錄下列警告,並改用網路要求 IP 位址:

      Decryption failed on the provided encrypted client IP (supplied via googletag.setConfig({ipAddress: ...})).