Skip to content

Commit

Permalink
Introduce bidding worklets
Browse files Browse the repository at this point in the history
This is the minimum working implementation. Features to be added later include:
- Arbitrary metadata instead of just a static price (google#147)
- trustedBiddingSignalsKeys (google#148)
- auctionSignals (google#149)
- browserSignals (google#150)
- Timeouts (google#151)
- Deduplication of script fetches (google#152)
- Optimization of worker support code (google#153)
- Public TypeScript typings for worklet script authors (google#154)

Fixes: google#20
  • Loading branch information
taymonbeal committed Jun 8, 2021
1 parent 0ab4a99 commit 4c5bbd0
Show file tree
Hide file tree
Showing 27 changed files with 2,319 additions and 239 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@ jobs:
- run: npm ci
- run: npm run check-format
- run: npm run build
env:
ALLOWED_LOGIC_URL_PREFIXES:
- run: npm run lint
- run: npm test
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@ being able to add new features to the browser itself.
This project has not yet been tested in production; use at your own risk.
Furthermore, most of the API is not yet implemented.

## Building

As with most JavaScript projects, you'll need Node.js and npm. Install
dependencies with `npm install` as per usual.

In order to build the frame, you have to set a list of allowed URL prefixes for
the worklets. The frame will only allow `biddingLogicUrl` and `decisionLogicUrl`
values that start with those prefixes. Each such prefix must consist of an HTTPS
origin optionally followed by a path, and must end with a slash. So, for
instance, you could allow worklet scripts under `https://dsp.example`, or
`https://ssp.example/js/`.

The reason for this is because worklet scripts have access to cross-site
interest group and related data, and nothing prevents them from exfiltrating
that data. So, if you're going to host the frame and have such cross-site data
stored in its origin in users' browsers, you should make sure to only allow
worklet scripts from sources that you trust not to do that.

Once you have an allowlist, set the `ALLOWED_LOGIC_URL_PREFIXES` environment
variable to the allowlist with the entries separated by commas, then run
`npm run build`. For example, on Mac or Linux, you might run
`ALLOWED_LOGIC_URL_PREFIXES=https://dsp.example/,https://ssp.example/js/ npm run build`;
on Windows PowerShell, the equivalent would be
`$Env:ALLOWED_LOGIC_URL_PREFIXES = "https://dsp.example/,https://ssp.example/js/"; npm run build`.

## Design

FLEDGE requires a way to store information in the browser that is (a) accessible
Expand Down
9 changes: 7 additions & 2 deletions fake_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ onfetch = async (fetchEvent) => {
}
const { port1: receiver, port2: sender } = new MessageChannel();
fetchEvent.respondWith(
new Promise((resolve) => {
receiver.onmessage = ({ data: [status, statusText, headers, body] }) => {
new Promise((resolve, reject) => {
receiver.onmessage = ({ data }) => {
receiver.close();
if (!data) {
reject();
return;
}
let [status, statusText, headers, body] = data;
if (body === null) {
// Cause any attempt to read the body to reject.
body = new ReadableStream({
Expand Down
119 changes: 96 additions & 23 deletions frame/auction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

/** @fileoverview Selection of ads, and creation of tokens to display them. */

import { AuctionAd, AuctionAdConfig } from "../lib/shared/api_types";
import { AuctionAdConfig } from "../lib/shared/api_types";
import { isKeyValueObject } from "../lib/shared/guards";
import { logWarning } from "./console";
import { forEachInterestGroup } from "./db_schema";
import { FetchJsonStatus, tryFetchJson } from "./fetch";
import { FetchStatus, tryFetchJavaScript, tryFetchJson } from "./fetch";
import { BidData, CanonicalInterestGroup } from "./types";
import { runBiddingScript } from "./worklet";

/**
* Selects the currently stored ad with the highest price, mints a token that
Expand All @@ -35,29 +37,56 @@ import { FetchJsonStatus, tryFetchJson } from "./fetch";
*
* @param hostname The hostname of the page where the FLEDGE Shim API is
* running.
* @param allowedLogicUrlPrefixes URL prefixes that worklet scripts are allowed
* to be sourced from.
* @param extraScript Additional JavaScript code to run in the Web Worker before
* anything else. Used only in tests.
*/
export async function runAdAuction(
{ trustedScoringSignalsUrl }: AuctionAdConfig,
hostname: string
hostname: string,
allowedLogicUrlPrefixes: readonly string[],
extraScript = ""
): Promise<string | boolean> {
let winner: AuctionAd | undefined;
const trustedBiddingSignalsUrls = new Set<string>();
const renderUrls = new Set<string>();
const bidPromises: Array<Promise<BidData | null>> = [];
if (
!(await forEachInterestGroup(({ trustedBiddingSignalsUrl, ads }) => {
if (trustedBiddingSignalsUrl !== undefined && ads.length) {
trustedBiddingSignalsUrls.add(trustedBiddingSignalsUrl);
!(await forEachInterestGroup((group: CanonicalInterestGroup) => {
const { biddingLogicUrl, trustedBiddingSignalsUrl, ads } = group;
if (biddingLogicUrl === undefined) {
return;
}
if (
!allowedLogicUrlPrefixes.some((prefix) =>
biddingLogicUrl.startsWith(prefix)
)
) {
logWarning("biddingLogicUrl is not allowlisted:", [biddingLogicUrl]);
return;
}
if (!ads.length) {
return;
}
for (const ad of ads) {
renderUrls.add(ad.renderUrl);
if (!winner || ad.metadata.price > winner.metadata.price) {
winner = ad;
}
if (trustedBiddingSignalsUrl !== undefined) {
trustedBiddingSignalsUrls.add(trustedBiddingSignalsUrl);
}
bidPromises.push(fetchScriptAndBid(biddingLogicUrl, group, extraScript));
}))
) {
return false;
}
let winner;
const renderUrls = new Set<string>();
for (const bidPromise of bidPromises) {
const bidData = await bidPromise;
if (!bidData) {
continue;
}
renderUrls.add(bidData.render);
if (!winner || bidData.bid > winner.bid) {
winner = bidData;
}
}
if (!winner) {
return true;
}
Expand All @@ -79,16 +108,52 @@ export async function runAdAuction(
})()
);
const token = randomToken();
sessionStorage.setItem(token, winner.renderUrl);
sessionStorage.setItem(token, winner.render);
return token;
}

function randomToken() {
return Array.prototype.map
.call(crypto.getRandomValues(new Uint8Array(16)), (byte: number) =>
byte.toString(/* radix= */ 16).padStart(2, "0")
)
.join("");
async function fetchScriptAndBid(
biddingLogicUrl: string,
group: CanonicalInterestGroup,
extraScript: string
) {
const fetchResult = await tryFetchJavaScript(biddingLogicUrl);
switch (fetchResult.status) {
case FetchStatus.OK: {
const bidData = await runBiddingScript(
fetchResult.value,
group,
extraScript
);
if (!bidData) {
// Error has already been logged in the Web Worker.
return null;
}
if (!group.ads.some(({ renderUrl }) => renderUrl === bidData.render)) {
logWarning("Bid render URL", [
bidData.render,
"is not in interest group:",
group,
]);
return null;
}
// Silently discard zero, negative, infinite, and NaN bids.
if (!(bidData.bid > 0 && bidData.bid < Infinity)) {
return null;
}
return bidData;
}
case FetchStatus.NETWORK_ERROR:
// Browser will have logged the error; no need to log it again.
return null;
case FetchStatus.VALIDATION_ERROR:
logWarning("Cannot use bidding script from", [
biddingLogicUrl,
": " + fetchResult.errorMessage,
...(fetchResult.errorData ?? []),
]);
return null;
}
}

async function fetchAndValidateTrustedSignals(
Expand All @@ -115,7 +180,7 @@ async function fetchAndValidateTrustedSignals(
const response = await tryFetchJson(url.href);
const basicErrorMessage = "Cannot use trusted scoring signals from";
switch (response.status) {
case FetchJsonStatus.OK: {
case FetchStatus.OK: {
const signals = response.value;
if (!isKeyValueObject(signals)) {
logWarning(basicErrorMessage, [
Expand All @@ -126,10 +191,10 @@ async function fetchAndValidateTrustedSignals(
}
return;
}
case FetchJsonStatus.NETWORK_ERROR:
case FetchStatus.NETWORK_ERROR:
// Browser will have logged the error; no need to log it again.
return;
case FetchJsonStatus.VALIDATION_ERROR:
case FetchStatus.VALIDATION_ERROR:
logWarning(basicErrorMessage, [
url.href,
": " + response.errorMessage,
Expand All @@ -138,3 +203,11 @@ async function fetchAndValidateTrustedSignals(
return;
}
}

function randomToken() {
return Array.prototype.map
.call(crypto.getRandomValues(new Uint8Array(16)), (byte: number) =>
byte.toString(/* radix= */ 16).padStart(2, "0")
)
.join("");
}
Loading

0 comments on commit 4c5bbd0

Please sign in to comment.