שרתי proxy לפרטיות בדפדפן והגדרות להגנה על כתובת ה-IP יכולים להסתיר את כתובות ה-IP של הלקוחות בבקשות להצגת מודעות של צד שלישי ב-Google Ad Manager. כתובות IP מוסתרות יכולות להפחית את הדיוק של הטירגוט הגיאוגרפי ואת הרלוונטיות של המודעות. מכיוון ששרת האינטרנט או הרשת להעברת תוכן (CDN) יוצרים אינטראקציה עם המשתמש בהקשר של נתונים שנאספים ישירות ממשתמשים, השרת מקבל את כתובת ה-IP של המשתמש בלי הסוואה.
התכונה "כתובת IP מבעל התוכן הדיגיטלי" מאפשרת לכם לתעד בשרת את כתובת ה-IPv4 או ה-IPv6 הגלויה של המשתמש, להצפין את הכתובת באמצעות הצפנה היברידית של מפתח ציבורי (HPKE) ולהעביר את המטען הייעודי (payload) המוצפן אל Google Publisher Tag (GPT) בצד הלקוח.
בדף הזה מוסבר איך לשלב את התכונה "כתובת IP מבעל תוכן דיגיטלי" עם GPT.
הטמעת תהליך העבודה בצד השרת
כדי למנוע חשיפה של כתובת ה-IP של המשתמש בטקסט פשוט לסקריפטים של צד שלישי בדפדפן, צריך לבצע את השלבים הבאים בשרת האינטרנט או ב-CDN edge worker:
- תיעוד כל כתובות ה-IP.
- מאחזרים את המפתח הציבורי.
- מבצעים הצפנת HPKE.
תיעוד כתובת ה-IP של הלקוח
מחלצים את כתובת ה-IPv4 או ה-IPv6 הגלויה של הלקוח מבקשת הדף הנכנסת בשרת האינטרנט או ב-CDN:
- חיבורים ישירים: קריאת כתובת השקע המרוחקת של חיבור ה-HTTP הנכנס.
- מאחורי CDN, מאזן עומסים או שרת proxy הפוך: קוראים את כותרת ה-IP של הלקוח משרת ה-proxy המהימן בקצה הרשת, כמו הכותרת
X-Forwarded-Forאו כותרת IP של לקוח ספציפי ל-CDN. חשוב לסמוך רק על כותרות העברה שתשתית ה-proxy מוסיפה או מחליפה. כשהכותרתX-Forwarded-Forמכילה כמה כתובות שמופרדות בפסיקים, בוחרים את כתובת ה-IP של הלקוח שנוספה על ידי שרת ה-proxy המהימן בקצה הרשת.
אפשר להשתמש ב-Ad Manager בכתובות הבאות:
- IPv4, לדוגמה,
203.0.113.42. - IPv6, לדוגמה,
2001:db8::8a2e:370:7334.
צריך להעביר רק את המחרוזת של כתובת ה-IP, בלי מספרי יציאות, סוגריים, קידומות CIDR או רווחים מסביב.
אחזור המפתח הציבורי ושמירה שלו במטמון
Google מפרסמת את מפתח ההצפנה הפעיל של HPKE כ-JSON Web Key Set (JWKS) של RFC 8037 בנקודת הקצה הבאה:
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 |
מזהה עקום אליפטי. תמיד X25519. |
kid |
מזהה מפתח בקידוד base64url ללא ריפוד, שמשתנה כש-Google מחליפה את המפתח. |
kty |
סוג המפתח. תמיד OKP (זוג מפתחות אוקטט). |
x |
מפתח ציבורי X25519 גולמי בגודל 32 בייטים בקידוד base64url ללא ריפוד, שמשמש להצפנה. |
שומרים במטמון את המפתח הציבורי שאוחזר בזיכרון של השרת, ומרעננים את המפתח לפחות פעם אחת כל 24 שעות, או מוקדם יותר בהתאם לכותרת התגובה Cache-Control של HTTP. אל תאחזרו את המפתח הציבורי בכל בקשת דף נכנסת.
Google שומרת מפתחות פרטיים שעברו רוטציה. מטענים ייעודיים (payloads) שמוצפנים באמצעות מפתח ציבורי שנשמר במטמון ב-24 השעות הקודמות, ממשיכים לעבור פענוח ללא הפרעה במהלך רוטציית המפתחות.
הצפנה של כתובת ה-IP
מצפינים את ייצוג הבייטים ב-UTF-8 של מחרוזת כתובת ה-IP של הלקוח באמצעות HPKE (RFC 9180) והמפתח הציבורי X25519 המפוענח בגודל 32 בייטים, x, שאוחזר באחזור המפתח הציבורי ושמירתו במטמון.
בדוגמה הבאה מוצגת הצפנה של מחרוזת כתובת 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 באמצעות הפרמטרים וסט האלגוריתמים להצפנה (cipher suite) הבאים:
| פרמטר | ערך |
|---|---|
| מנגנון אנקפסולציה של מפתח (KEM) | DHKEM(X25519, HKDF-SHA256) (0x0020) |
| פונקציית גזירת מפתחות (KDF) | HKDF-SHA256 (0x0001) |
| הצפנה מאומתת עם מידע נלווה (AEAD) | AES-256-GCM (0x0002) |
| מצב | בסיס (0x00) |
פרטי האפליקציה (info) |
ריק ("") |
נתונים משויכים (aad) |
ריק ("") |
הפלט המוצפן צריך להיות בפורמט של שרשור גולמי של המפתח הציבורי הארעי המאוגד בגודל 32 בייטים, enc, והטקסט המוצפן של AEAD, כולל תג האימות של GCM בגודל 16 בייטים, ללא Tink או קידומת כותרת מותאמת אישית. מקודדים את התוצאה כמחרוזת base64url ללא ריפוד.
הצפינו כל מטען ייעודי (payload) כהודעת HPKE חד-פעמית נפרדת עם זוג מפתחות זמניים חדשים. אם ספריית ה-HPKE שלכם משתמשת בהקשר של שולח עם שמירת מצב, צרו הקשר חדש לכל הצפנה במקום להשתמש בו שוב בכמה הודעות. הצפינו את כתובת ה-IP של הלקוח לכל בקשת דף, או שמרו במטמון את מחרוזת המידע המוצפן (ciphertext) של base64url שנוצרה לכל כתובת IP למשך זמן קצר (TTL).
הטמעת תהליך העבודה בצד הלקוח
אחרי שהשרת יוצר את המטען הייעודי (payload) המוצפן של כתובת ה-IP בפורמט base64url, מעבירים את המטען הייעודי ל-GPT בצד הלקוח לפני ששולחים בקשות להצגת מודעות.
העברת כתובת ה-IP המוצפנת אל GPT
מעבירים את מחרוזת ה-IP המוצפנת ל-method googletag.setConfig באמצעות המאפיין ipAddress ב-PageSettingsConfig.
בדוגמה הבאה, המטען הייעודי (payload) המוצפן של כתובת ה-IP שנוצר על ידי השרת מוכנס ל-method 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 שולח את המטען הייעודי (payload) המוצפן של כתובת ה-IP ו-Ad Manager מפענח את המטען הייעודי, משתמשים בכלי הפיתוח של הדפדפן וב-Google Publisher Console:
כלים למפתחים בדפדפן
- פותחים את הכלים למפתחים בדפדפן.
- בוחרים בכרטיסייה רשת.
- מסננים את הבקשות להצגת מודעות לפי
/gampad/ads. - מוודאים שכתובת ה-URL של הבקשה כוללת פרמטר שאילתה
ipsלא ריק. מכיוון ש-GPT מקודד עוד יותר את הערךipAddressלפני שליחת הבקשה להצגת מודעה, הפרמטרipsלא תואם למחרוזת המוצפנת שלך באופן מילולי.
מרכז המפרסמים של Google
- כדי לפתוח את המסוף, פועלים לפי השלבים במדריך של Google Publisher Console.
- לוחצים על הכרטיסייה בקשת דף.
מוודאים שלא מופיעות אזהרות של
ipAddress. אם יש בעיה במטען הייעודי (payload), אחת מהאזהרות הבאות תופיע ביומן של מסוף הדפדפן:אם הערך שמועבר למאפיין
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 לא מצליחה לפענח את המטען הייעודי (payload) של כתובת ה-IP שסופקה, או אם הטקסט הפשוט שפוענח הוא לא כתובת IPv4 או IPv6 תקינה, המסוף מתעד את האזהרה הבאה וחוזר לכתובת ה-IP של הבקשה לאחזור מהרשת:
Decryption failed on the provided encrypted client IP (supplied via googletag.setConfig({ipAddress: ...})).