summaryrefslogtreecommitdiff
path: root/adservices/service-core/java/com/android/adservices/service/measurement/registration/AsyncSourceFetcher.java
diff options
context:
space:
mode:
Diffstat (limited to 'adservices/service-core/java/com/android/adservices/service/measurement/registration/AsyncSourceFetcher.java')
-rw-r--r--adservices/service-core/java/com/android/adservices/service/measurement/registration/AsyncSourceFetcher.java466
1 files changed, 466 insertions, 0 deletions
diff --git a/adservices/service-core/java/com/android/adservices/service/measurement/registration/AsyncSourceFetcher.java b/adservices/service-core/java/com/android/adservices/service/measurement/registration/AsyncSourceFetcher.java
new file mode 100644
index 0000000000..33c0a53245
--- /dev/null
+++ b/adservices/service-core/java/com/android/adservices/service/measurement/registration/AsyncSourceFetcher.java
@@ -0,0 +1,466 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.adservices.service.measurement.registration;
+
+import static com.android.adservices.service.measurement.PrivacyParams.MAX_INSTALL_ATTRIBUTION_WINDOW;
+import static com.android.adservices.service.measurement.PrivacyParams.MAX_POST_INSTALL_EXCLUSIVITY_WINDOW;
+import static com.android.adservices.service.measurement.PrivacyParams.MAX_REPORTING_REGISTER_SOURCE_EXPIRATION_IN_SECONDS;
+import static com.android.adservices.service.measurement.PrivacyParams.MIN_INSTALL_ATTRIBUTION_WINDOW;
+import static com.android.adservices.service.measurement.PrivacyParams.MIN_POST_INSTALL_EXCLUSIVITY_WINDOW;
+import static com.android.adservices.service.measurement.PrivacyParams.MIN_REPORTING_REGISTER_SOURCE_EXPIRATION_IN_SECONDS;
+import static com.android.adservices.service.measurement.SystemHealthParams.MAX_AGGREGATE_KEYS_PER_REGISTRATION;
+import static com.android.adservices.service.measurement.util.BaseUriExtractor.getBaseUri;
+import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_MEASUREMENT_REGISTRATIONS__TYPE__SOURCE;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.net.Uri;
+
+import com.android.adservices.LogUtil;
+import com.android.adservices.data.enrollment.EnrollmentDao;
+import com.android.adservices.service.Flags;
+import com.android.adservices.service.FlagsFactory;
+import com.android.adservices.service.measurement.AsyncRegistration;
+import com.android.adservices.service.measurement.EventSurfaceType;
+import com.android.adservices.service.measurement.MeasurementHttpClient;
+import com.android.adservices.service.measurement.Source;
+import com.android.adservices.service.measurement.util.AsyncFetchStatus;
+import com.android.adservices.service.measurement.util.AsyncRedirect;
+import com.android.adservices.service.measurement.util.Enrollment;
+import com.android.adservices.service.measurement.util.UnsignedLong;
+import com.android.adservices.service.measurement.util.Web;
+import com.android.adservices.service.stats.AdServicesLogger;
+import com.android.adservices.service.stats.AdServicesLoggerImpl;
+import com.android.internal.annotations.VisibleForTesting;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Download and decode Response Based Registration
+ *
+ * @hide
+ */
+public class AsyncSourceFetcher {
+
+ private final String mDefaultAndroidAppScheme = "android-app";
+ private final String mDefaultAndroidAppUriPrefix = mDefaultAndroidAppScheme + "://";
+ private final MeasurementHttpClient mNetworkConnection = new MeasurementHttpClient();
+ private final EnrollmentDao mEnrollmentDao;
+ private final Flags mFlags;
+ private final AdServicesLogger mLogger;
+
+ public AsyncSourceFetcher(Context context) {
+ this(
+ EnrollmentDao.getInstance(context),
+ FlagsFactory.getFlags(),
+ AdServicesLoggerImpl.getInstance());
+ }
+
+ @VisibleForTesting
+ public AsyncSourceFetcher(EnrollmentDao enrollmentDao, Flags flags, AdServicesLogger logger) {
+ mEnrollmentDao = enrollmentDao;
+ mFlags = flags;
+ mLogger = logger;
+ }
+
+ private boolean parseCommonSourceParams(
+ @NonNull JSONObject json,
+ @Nullable Uri appDestinationFromRequest,
+ @Nullable Uri webDestinationFromRequest,
+ long sourceEventTime,
+ boolean shouldValidateDestination,
+ Source.Builder result,
+ boolean isWebSource,
+ boolean isAllowDebugKey,
+ boolean isAdIdPermissionGranted)
+ throws JSONException {
+ final boolean hasRequiredParams = hasRequiredParams(json, shouldValidateDestination);
+ if (!hasRequiredParams) {
+ throw new JSONException(
+ String.format(
+ "Expected %s and a destination", SourceHeaderContract.SOURCE_EVENT_ID));
+ }
+ result.setEventId(new UnsignedLong(json.getString(SourceHeaderContract.SOURCE_EVENT_ID)));
+ if (!json.isNull(SourceHeaderContract.EXPIRY)) {
+ long expiry =
+ extractValidNumberInRange(
+ json.getLong(SourceHeaderContract.EXPIRY),
+ MIN_REPORTING_REGISTER_SOURCE_EXPIRATION_IN_SECONDS,
+ MAX_REPORTING_REGISTER_SOURCE_EXPIRATION_IN_SECONDS);
+ result.setExpiryTime(sourceEventTime + TimeUnit.SECONDS.toMillis(expiry));
+ } else {
+ result.setExpiryTime(
+ sourceEventTime
+ + TimeUnit.SECONDS.toMillis(
+ MAX_REPORTING_REGISTER_SOURCE_EXPIRATION_IN_SECONDS));
+ }
+ if (!json.isNull(SourceHeaderContract.PRIORITY)) {
+ result.setPriority(json.getLong(SourceHeaderContract.PRIORITY));
+ }
+ boolean isWebAllow = isWebSource && isAllowDebugKey && isAdIdPermissionGranted;
+ boolean isAppAllow = !isWebSource && isAdIdPermissionGranted;
+ if (!json.isNull(SourceHeaderContract.DEBUG_KEY) && (isWebAllow || isAppAllow)) {
+ try {
+ result.setDebugKey(
+ new UnsignedLong(json.getString(SourceHeaderContract.DEBUG_KEY)));
+ } catch (NumberFormatException e) {
+ LogUtil.e(e, "parseCommonSourceParams: parsing debug key failed");
+ }
+ }
+ if (!json.isNull(SourceHeaderContract.INSTALL_ATTRIBUTION_WINDOW_KEY)) {
+ long installAttributionWindow =
+ extractValidNumberInRange(
+ json.getLong(SourceHeaderContract.INSTALL_ATTRIBUTION_WINDOW_KEY),
+ MIN_INSTALL_ATTRIBUTION_WINDOW,
+ MAX_INSTALL_ATTRIBUTION_WINDOW);
+ result.setInstallAttributionWindow(TimeUnit.SECONDS.toMillis(installAttributionWindow));
+ } else {
+ result.setInstallAttributionWindow(
+ TimeUnit.SECONDS.toMillis(MAX_INSTALL_ATTRIBUTION_WINDOW));
+ }
+ if (!json.isNull(SourceHeaderContract.POST_INSTALL_EXCLUSIVITY_WINDOW_KEY)) {
+ long installCooldownWindow =
+ extractValidNumberInRange(
+ json.getLong(SourceHeaderContract.POST_INSTALL_EXCLUSIVITY_WINDOW_KEY),
+ MIN_POST_INSTALL_EXCLUSIVITY_WINDOW,
+ MAX_POST_INSTALL_EXCLUSIVITY_WINDOW);
+ result.setInstallCooldownWindow(TimeUnit.SECONDS.toMillis(installCooldownWindow));
+ } else {
+ result.setInstallCooldownWindow(
+ TimeUnit.SECONDS.toMillis(MIN_POST_INSTALL_EXCLUSIVITY_WINDOW));
+ }
+ // This "filter_data" field is used to generate reports.
+ if (!json.isNull(SourceHeaderContract.FILTER_DATA)) {
+ if (!FetcherUtil.areValidAttributionFilters(
+ json.optJSONObject(SourceHeaderContract.FILTER_DATA))) {
+ LogUtil.d("Source filter-data is invalid.");
+ return false;
+ }
+ result.setFilterData(
+ json.getJSONObject(SourceHeaderContract.FILTER_DATA).toString());
+ }
+ if (!json.isNull(SourceHeaderContract.DESTINATION)) {
+ Uri appUri = Uri.parse(json.getString(SourceHeaderContract.DESTINATION));
+ if (appUri.getScheme() == null) {
+ LogUtil.d("App destination is missing app scheme, adding.");
+ appUri = Uri.parse(mDefaultAndroidAppUriPrefix + appUri);
+ }
+ if (!mDefaultAndroidAppScheme.equals(appUri.getScheme())) {
+ LogUtil.e(
+ "Invalid scheme for app destination: %s; dropping the source.",
+ appUri.getScheme());
+ return false;
+ }
+ if (appDestinationFromRequest != null && !appDestinationFromRequest.equals(appUri)) {
+ LogUtil.d("Expected destination to match with the supplied one!");
+ return false;
+ }
+ result.setAppDestination(getBaseUri(appUri));
+ }
+ if (shouldValidateDestination
+ && !doUriFieldsMatch(
+ json, SourceHeaderContract.WEB_DESTINATION, webDestinationFromRequest)) {
+ LogUtil.d("Expected web_destination to match with ths supplied one!");
+ return false;
+ }
+ if (!json.isNull(SourceHeaderContract.WEB_DESTINATION)) {
+ Uri webDestination = Uri.parse(json.getString(SourceHeaderContract.WEB_DESTINATION));
+ Optional<Uri> topPrivateDomainAndScheme = Web.topPrivateDomainAndScheme(webDestination);
+ if (!topPrivateDomainAndScheme.isPresent()) {
+ LogUtil.d("Unable to extract top private domain and scheme from web destination.");
+ return false;
+ } else {
+ result.setWebDestination(topPrivateDomainAndScheme.get());
+ }
+ }
+ return true;
+ }
+
+ private boolean parseSource(
+ @NonNull Uri publisher,
+ @NonNull String enrollmentId,
+ @Nullable Uri appDestination,
+ @Nullable Uri webDestination,
+ @Nullable Uri registrant,
+ long eventTime,
+ @Nullable Source.SourceType sourceType,
+ boolean shouldValidateDestination,
+ @NonNull Map<String, List<String>> headers,
+ @NonNull List<Source> sources,
+ boolean isWebSource,
+ boolean isAllowDebugKey,
+ boolean isAdIdPermissionGranted) {
+ Source.Builder result = new Source.Builder();
+ result.setPublisher(publisher);
+ result.setEnrollmentId(enrollmentId);
+ result.setRegistrant(registrant);
+ result.setSourceType(sourceType);
+ result.setAttributionMode(Source.AttributionMode.TRUTHFULLY);
+ result.setEventTime(eventTime);
+ result.setPublisherType(isWebSource ? EventSurfaceType.WEB : EventSurfaceType.APP);
+ List<String> field = headers.get("Attribution-Reporting-Register-Source");
+ if (field == null || field.size() != 1) {
+ LogUtil.d(
+ "AsyncSourceFetcher: "
+ + "Invalid Attribution-Reporting-Register-Source header.");
+ return false;
+ }
+ try {
+ JSONObject json = new JSONObject(field.get(0));
+ boolean isValid =
+ parseCommonSourceParams(
+ json,
+ appDestination,
+ webDestination,
+ eventTime,
+ shouldValidateDestination,
+ result,
+ isWebSource,
+ isAllowDebugKey,
+ isAdIdPermissionGranted);
+ if (!isValid) {
+ return false;
+ }
+ if (!json.isNull(SourceHeaderContract.AGGREGATION_KEYS)) {
+ if (!areValidAggregationKeys(
+ json.getJSONObject(SourceHeaderContract.AGGREGATION_KEYS))) {
+ return false;
+ }
+ result.setAggregateSource(json.getString(SourceHeaderContract.AGGREGATION_KEYS));
+ }
+ sources.add(result.build());
+ return true;
+ } catch (JSONException | NumberFormatException e) {
+ LogUtil.d(e, "AsyncSourceFetcher: Invalid JSON");
+ return false;
+ }
+ }
+
+ private static boolean hasRequiredParams(JSONObject json, boolean shouldValidateDestinations) {
+ boolean isDestinationAvailable = !json.isNull(SourceHeaderContract.DESTINATION);
+ if (shouldValidateDestinations) {
+ isDestinationAvailable |= !json.isNull(SourceHeaderContract.WEB_DESTINATION);
+ }
+ return !json.isNull(SourceHeaderContract.SOURCE_EVENT_ID) && isDestinationAvailable;
+ }
+
+ private static boolean doUriFieldsMatch(JSONObject json, String fieldName, Uri expectedValue)
+ throws JSONException {
+ if (json.isNull(fieldName) && expectedValue == null) {
+ return true;
+ }
+ return !json.isNull(fieldName)
+ && Objects.equals(expectedValue, Uri.parse(json.getString(fieldName)));
+ }
+
+ private static long extractValidNumberInRange(long value, long lowerLimit, long upperLimit) {
+ if (value < lowerLimit) {
+ return lowerLimit;
+ } else if (value > upperLimit) {
+ return upperLimit;
+ }
+ return value;
+ }
+
+ /** Provided a testing hook. */
+ @NonNull
+ @VisibleForTesting
+ public URLConnection openUrl(@NonNull URL url) throws IOException {
+ return mNetworkConnection.setup(url);
+ }
+
+ /**
+ * Fetch a source type registration.
+ *
+ * @param asyncRegistration a {@link AsyncRegistration}, a request the record.
+ * @param asyncFetchStatus a {@link AsyncFetchStatus}, stores Ad Tech server status.
+ */
+ public Optional<Source> fetchSource(
+ @NonNull AsyncRegistration asyncRegistration,
+ @NonNull AsyncFetchStatus asyncFetchStatus,
+ AsyncRedirect asyncRedirect) {
+ List<Source> out = new ArrayList<>();
+ fetchSource(
+ asyncRegistration.getTopOrigin(),
+ asyncRegistration.getRegistrationUri(),
+ asyncRegistration.getOsDestination(),
+ asyncRegistration.getWebDestination(),
+ asyncRegistration.getRegistrant(),
+ asyncRegistration.getRequestTime(),
+ asyncRegistration.getType() == AsyncRegistration.RegistrationType.WEB_SOURCE,
+ asyncRegistration.getSourceType(),
+ out,
+ asyncRegistration.shouldProcessRedirects(),
+ asyncRegistration.getRedirectType(),
+ asyncRedirect,
+ asyncRegistration.getType() == AsyncRegistration.RegistrationType.WEB_SOURCE,
+ asyncRegistration.getDebugKeyAllowed(),
+ asyncFetchStatus,
+ asyncRegistration.getDebugKeyAllowed());
+ if (out.isEmpty()) {
+ return Optional.empty();
+ } else {
+ return Optional.of(out.get(0));
+ }
+ }
+
+ private void fetchSource(
+ @NonNull Uri publisher,
+ @NonNull Uri registrationUri,
+ @Nullable Uri appDestination,
+ @Nullable Uri webDestination,
+ @Nullable Uri registrant,
+ long eventTime,
+ boolean shouldValidateDestination,
+ @NonNull Source.SourceType sourceType,
+ @NonNull List<Source> sourceOut,
+ boolean shouldProcessRedirects,
+ @AsyncRegistration.RedirectType int redirectType,
+ @NonNull AsyncRedirect asyncRedirect,
+ boolean isWebSource,
+ boolean isAllowDebugKey,
+ @Nullable AsyncFetchStatus asyncFetchStatus,
+ boolean isAdIdPermissionGranted) {
+ // Require https.
+ if (!registrationUri.getScheme().equals("https")) {
+ asyncFetchStatus.setStatus(AsyncFetchStatus.ResponseStatus.PARSING_ERROR);
+ return;
+ }
+ URL url;
+ try {
+ url = new URL(registrationUri.toString());
+ } catch (MalformedURLException e) {
+ LogUtil.d(e, "Malformed registration target URL");
+ return;
+ }
+ Optional<String> enrollmentId =
+ Enrollment.maybeGetEnrollmentId(registrationUri, mEnrollmentDao);
+ if (!enrollmentId.isPresent()) {
+ asyncFetchStatus.setStatus(AsyncFetchStatus.ResponseStatus.INVALID_ENROLLMENT);
+ LogUtil.d(
+ "fetchSource: unable to find enrollment ID. Registration URI: %s",
+ registrationUri);
+ return;
+ }
+ HttpURLConnection urlConnection;
+ try {
+ urlConnection = (HttpURLConnection) openUrl(url);
+ } catch (IOException e) {
+ asyncFetchStatus.setStatus(AsyncFetchStatus.ResponseStatus.NETWORK_ERROR);
+ LogUtil.e(e, "Failed to open registration target URL");
+ return;
+ }
+ try {
+ urlConnection.setRequestMethod("POST");
+ urlConnection.setRequestProperty(
+ "Attribution-Reporting-Source-Info", sourceType.toString());
+ urlConnection.setInstanceFollowRedirects(false);
+ Map<String, List<String>> headers = urlConnection.getHeaderFields();
+ FetcherUtil.emitHeaderMetrics(
+ mFlags,
+ mLogger,
+ AD_SERVICES_MEASUREMENT_REGISTRATIONS__TYPE__SOURCE,
+ headers,
+ registrationUri);
+ int responseCode = urlConnection.getResponseCode();
+ if (!FetcherUtil.isRedirect(responseCode) && !FetcherUtil.isSuccess(responseCode)) {
+ asyncFetchStatus.setStatus(AsyncFetchStatus.ResponseStatus.SERVER_UNAVAILABLE);
+ return;
+ }
+ asyncFetchStatus.setStatus(AsyncFetchStatus.ResponseStatus.SUCCESS);
+ final boolean parsed =
+ parseSource(
+ publisher,
+ enrollmentId.get(),
+ appDestination,
+ webDestination,
+ registrant,
+ eventTime,
+ sourceType,
+ shouldValidateDestination,
+ headers,
+ sourceOut,
+ isWebSource,
+ isAllowDebugKey,
+ isAdIdPermissionGranted);
+ if (!parsed) {
+ asyncFetchStatus.setStatus(AsyncFetchStatus.ResponseStatus.PARSING_ERROR);
+ LogUtil.d("Failed to parse");
+ return;
+ }
+ if (shouldProcessRedirects) {
+ AsyncRedirect redirectsAndType = FetcherUtil.parseRedirects(headers, redirectType);
+ asyncRedirect.addToRedirects(redirectsAndType.getRedirects());
+ asyncRedirect.setRedirectType(redirectsAndType.getRedirectType());
+ } else {
+ asyncRedirect.setRedirectType(redirectType);
+ }
+ } catch (IOException e) {
+ asyncFetchStatus.setStatus(AsyncFetchStatus.ResponseStatus.NETWORK_ERROR);
+ LogUtil.e(e, "Failed to get registration response");
+ } finally {
+ urlConnection.disconnect();
+ }
+ }
+
+ private boolean areValidAggregationKeys(JSONObject aggregationKeys) {
+ if (aggregationKeys.length() > MAX_AGGREGATE_KEYS_PER_REGISTRATION) {
+ LogUtil.d(
+ "Aggregation-keys have more entries than permitted. %s",
+ aggregationKeys.length());
+ return false;
+ }
+ for (String id : aggregationKeys.keySet()) {
+ if (!FetcherUtil.isValidAggregateKeyId(id)) {
+ LogUtil.d("SourceFetcher: aggregation key ID is invalid. %s", id);
+ return false;
+ }
+ String keyPiece = aggregationKeys.optString(id);
+ if (!FetcherUtil.isValidAggregateKeyPiece(keyPiece)) {
+ LogUtil.d("SourceFetcher: aggregation key-piece is invalid. %s", keyPiece);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private interface SourceHeaderContract {
+ String SOURCE_EVENT_ID = "source_event_id";
+ String DEBUG_KEY = "debug_key";
+ String DESTINATION = "destination";
+ String EXPIRY = "expiry";
+ String PRIORITY = "priority";
+ String INSTALL_ATTRIBUTION_WINDOW_KEY = "install_attribution_window";
+ String POST_INSTALL_EXCLUSIVITY_WINDOW_KEY = "post_install_exclusivity_window";
+ String FILTER_DATA = "filter_data";
+ String WEB_DESTINATION = "web_destination";
+ String AGGREGATION_KEYS = "aggregation_keys";
+ }
+}