/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

#include "CookieValidation.h"
#include "CookieLogging.h"
#include "CookieService.h"
#include "CookiePrefixes.h"
#include "mozilla/dom/nsMixedContentBlocker.h"
#include "mozilla/StaticPrefs_network.h"

constexpr uint32_t kMaxBytesPerCookie = 4096;
constexpr uint32_t kMaxBytesPerDomain = 1024;
constexpr uint32_t kMaxBytesPerPath = 1024;

using namespace mozilla::net;

NS_IMPL_ISUPPORTS(CookieValidation, nsICookieValidation)

CookieValidation::CookieValidation(const CookieStruct& aCookieData)
    : mCookieData(aCookieData) {}

// static
already_AddRefed<CookieValidation> CookieValidation::Validate(
    const CookieStruct& aCookieData) {
  RefPtr<CookieValidation> cv = new CookieValidation(aCookieData);
  cv->ValidateInternal();
  return cv.forget();
}

// static
already_AddRefed<CookieValidation> CookieValidation::ValidateForHost(
    const CookieStruct& aCookieData, nsIURI* aHostURI,
    const nsACString& aBaseDomain, bool aRequireHostMatch, bool aFromHttp) {
  RefPtr<CookieValidation> cv = new CookieValidation(aCookieData);
  cv->ValidateForHostInternal(aHostURI, aBaseDomain, aRequireHostMatch,
                              aFromHttp);
  return cv.forget();
}

// static
already_AddRefed<CookieValidation> CookieValidation::ValidateInContext(
    const CookieStruct& aCookieData, nsIURI* aHostURI,
    const nsACString& aBaseDomain, bool aRequireHostMatch, bool aFromHttp,
    bool aIsForeignAndNotAddon, bool aPartitionedOnly,
    bool aIsInPrivateBrowsing) {
  RefPtr<CookieValidation> cv = new CookieValidation(aCookieData);
  cv->ValidateInContextInternal(aHostURI, aBaseDomain, aRequireHostMatch,
                                aFromHttp, aIsForeignAndNotAddon,
                                aPartitionedOnly, aIsInPrivateBrowsing);
  return cv.forget();
}

void CookieValidation::ValidateInternal() {
  MOZ_ASSERT(mResult == eOK);

  // reject cookie if name and value are empty, per RFC6265bis
  if (mCookieData.name().IsEmpty() && mCookieData.value().IsEmpty()) {
    mResult = eRejectedEmptyNameAndValue;
    return;
  }

  // reject cookie if it's over the size limit, per RFC2109
  if (!CheckNameAndValueSize(mCookieData)) {
    mResult = eRejectedNameValueOversize;
    return;
  }

  if (!CheckName(mCookieData)) {
    mResult = eRejectedInvalidCharName;
    return;
  }

  if (!CheckValue(mCookieData)) {
    mResult = eRejectedInvalidCharValue;
    return;
  }

  if (mCookieData.path().Length() > kMaxBytesPerPath) {
    mResult = eRejectedAttributePathOversize;
    return;
  }

  if (mCookieData.path().Contains('\t')) {
    mResult = eRejectedInvalidPath;
    return;
  }

  if (mCookieData.host().Length() > kMaxBytesPerDomain) {
    mResult = eRejectedAttributeDomainOversize;
    return;
  }

  // If a cookie is nameless, then its value must not start with a known prefix.
  if (mCookieData.name().IsEmpty() &&
      CookiePrefixes::Has(mCookieData.value())) {
    mResult = eRejectedInvalidPrefix;
    return;
  }

  // If same-site is explicitly set to 'none' but this is not a secure context,
  // let's abort the parsing.
  if (!mCookieData.isSecure() &&
      mCookieData.sameSite() == nsICookie::SAMESITE_NONE) {
    if (StaticPrefs::network_cookie_sameSite_noneRequiresSecure()) {
      mResult = eRejectedNoneRequiresSecure;
      return;
    }

    // Still warn about the missing Secure attribute when not enforcing.
    mWarnings.mSameSiteNoneRequiresSecureForBeta = true;
  }

  // This part checks if the caleers have set the expiry value to max 400 days.
  if (!mCookieData.isSession()) {
    int64_t maxageCap = StaticPrefs::network_cookie_maxageCap();
    int64_t creationTimeInMSec =
        mCookieData.updateTimeInUSec() / PR_USEC_PER_MSEC;
    int64_t expiryInMSec = mCookieData.expiryInMSec();
    if (maxageCap && expiryInMSec > creationTimeInMSec + maxageCap * 1000) {
      mResult = eRejectedAttributeExpiryOversize;
      return;
    }
  }
}

void CookieValidation::ValidateForHostInternal(nsIURI* aHostURI,
                                               const nsACString& aBaseDomain,
                                               bool aRequireHostMatch,
                                               bool aFromHttp) {
  MOZ_ASSERT(mResult == eOK);

  ValidateInternal();
  if (mResult != eOK) {
    return;
  }

  if (!aBaseDomain.IsEmpty() &&
      !CheckDomain(mCookieData, aHostURI, aBaseDomain, aRequireHostMatch)) {
    mResult = eRejectedInvalidDomain;
    return;
  }

  if ((mCookieData.schemeMap() & nsICookie::SCHEME_FILE) &&
      !mCookieData.host().IsEmpty()) {
    mResult = eRejectedInvalidDomain;
    return;
  }

  // if the new cookie is httponly, make sure we're not coming from script
  if (!aFromHttp && mCookieData.isHttpOnly()) {
    mResult = eRejectedHttpOnlyButFromScript;
    return;
  }

  bool potentiallyTrustworthy =
      nsMixedContentBlocker::IsPotentiallyTrustworthyOrigin(aHostURI);

  // FixDomain() and FixPath() from CookieParser MUST be run first to make sure
  // invalid attributes are rejected and to regularlize them. In particular all
  // explicit domain attributes result in a host that starts with a dot, and if
  // the host doesn't start with a dot it correctly matches the true
  // host.
  if (!CookiePrefixes::Check(mCookieData, potentiallyTrustworthy)) {
    mResult = eRejectedInvalidPrefix;
    return;
  }

  // If the new cookie is non-https and wants to set secure flag,
  // browser have to ignore this new cookie.
  // (draft-ietf-httpbis-cookie-alone section 3.1)
  if (mCookieData.isSecure() && !potentiallyTrustworthy) {
    mResult = eRejectedSecureButNonHttps;
    return;
  }

  if (mCookieData.sameSite() == nsICookie::SAMESITE_UNSET) {
    bool laxByDefault =
        StaticPrefs::network_cookie_sameSite_laxByDefault() &&
        !nsContentUtils::IsURIInPrefList(
            aHostURI, "network.cookie.sameSite.laxByDefault.disabledHosts");
    if (laxByDefault) {
      mWarnings.mSameSiteLaxForced = true;
    } else if (StaticPrefs::
                   network_cookie_sameSite_laxByDefaultWarningsForBeta()) {
      mWarnings.mSameSiteLaxForcedForBeta = true;
    }
  }
}

void CookieValidation::ValidateInContextInternal(
    nsIURI* aHostURI, const nsACString& aBaseDomain, bool aRequireHostMatch,
    bool aFromHttp, bool aIsForeignAndNotAddon, bool aPartitionedOnly,
    bool aIsInPrivateBrowsing) {
  MOZ_ASSERT(mResult == eOK);

  ValidateForHostInternal(aHostURI, aBaseDomain, aRequireHostMatch, aFromHttp);
  if (mResult != eOK) {
    return;
  }

  // If the cookie is same-site but in a cross site context, browser must
  // ignore the cookie.
  bool laxByDefault =
      StaticPrefs::network_cookie_sameSite_laxByDefault() &&
      !nsContentUtils::IsURIInPrefList(
          aHostURI, "network.cookie.sameSite.laxByDefault.disabledHosts");
  uint32_t sameSite = mCookieData.sameSite();
  if (sameSite == nsICookie::SAMESITE_UNSET) {
    sameSite =
        laxByDefault ? nsICookie::SAMESITE_LAX : nsICookie::SAMESITE_NONE;
  }

  if (sameSite != nsICookie::SAMESITE_NONE && aIsForeignAndNotAddon) {
    mResult = eRejectedForNonSameSiteness;
    return;
  }

  // Ensure the partitioned cookie is set with the secure attribute if CHIPS
  // is enabled. This check should be part of ValidateInternal but it's not
  // because of bug 1965880.
  if (StaticPrefs::network_cookie_CHIPS_enabled() &&
      mCookieData.isPartitioned() && !mCookieData.isSecure()) {
    mResult = eRejectedPartitionedRequiresSecure;
    return;
  }
}

NS_IMETHODIMP
CookieValidation::GetResult(nsICookieValidation::ValidationError* aRetval) {
  NS_ENSURE_ARG_POINTER(aRetval);
  *aRetval = mResult;
  return NS_OK;
}

// static
bool CookieValidation::CheckDomain(const CookieStruct& aCookieData,
                                   nsIURI* aHostURI,
                                   const nsACString& aBaseDomain,
                                   bool aRequireHostMatch) {
  // Note: The logic in this function is mirrored in
  // toolkit/components/extensions/ext-cookies.js:checkSetCookiePermissions().
  // If it changes, please update that function, or file a bug for someone
  // else to do so.

  if (aCookieData.host().IsEmpty()) {
    return false;
  }

  // get host from aHostURI
  nsAutoCString hostFromURI;
  nsContentUtils::GetHostOrIPv6WithBrackets(aHostURI, hostFromURI);

  // check whether the host is either an IP address, an alias such as
  // 'localhost', an eTLD such as 'co.uk', or the empty string. in these
  // cases, require an exact string match for the domain, and leave the cookie
  // as a non-domain one. bug 105917 originally noted the requirement to deal
  // with IP addresses.
  if (aRequireHostMatch) {
    return hostFromURI.Equals(aCookieData.host());
  }

  nsCString cookieHost = aCookieData.host();
  // Tolerate leading '.' characters, but not if it's otherwise an empty host.
  if (aCookieData.host().Length() > 1 && aCookieData.host().First() == '.') {
    cookieHost.Cut(0, 1);
  }

  // ensure the proposed domain is derived from the base domain; and also
  // that the host domain is derived from the proposed domain (per RFC2109).
  if (CookieCommons::IsSubdomainOf(cookieHost, aBaseDomain) &&
      CookieCommons::IsSubdomainOf(hostFromURI, cookieHost)) {
    return true;
  }

  /*
   * note: RFC2109 section 4.3.2 requires that we check the following:
   * that the portion of host not in domain does not contain a dot.
   * this prevents hosts of the form x.y.co.nz from setting cookies in the
   * entire .co.nz domain. however, it's only a only a partial solution and
   * it breaks sites (IE doesn't enforce it), so we don't perform this check.
   */
  return false;
}

void CookieValidation::RetrieveErrorLogData(uint32_t* aFlags,
                                            nsACString& aCategory,
                                            nsACString& aKey,
                                            nsTArray<nsString>& aParams) const {
  MOZ_ASSERT(aFlags);
  MOZ_ASSERT(aParams.IsEmpty());

  *aFlags = nsIScriptError::errorFlag;

#define SET_LOG_DATA(category, x) \
  aCategory = category;           \
  aKey = x;                       \
  aParams.AppendElement(NS_ConvertUTF8toUTF16(mCookieData.name()));

  switch (mResult) {
    case eOK:
      return;

    case eRejectedEmptyNameAndValue: {
      *aFlags = nsIScriptError::warningFlag;
      aCategory.Assign(CONSOLE_REJECTION_CATEGORY);
      aKey.Assign("CookieRejectedEmptyNameAndValue"_ns);
      return;
    }

    case eRejectedNoneRequiresSecure: {
      SET_LOG_DATA(CONSOLE_SAMESITE_CATEGORY,
                   "CookieRejectedNonRequiresSecure2"_ns);
      return;
    }

    case eRejectedPartitionedRequiresSecure: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedPartitionedRequiresSecure"_ns);
      return;
    }

    case eRejectedNameValueOversize: {
      *aFlags = nsIScriptError::warningFlag;
      aCategory.Assign(CONSOLE_OVERSIZE_CATEGORY);
      aKey.Assign("CookieOversize"_ns);

      aParams.AppendElement(NS_ConvertUTF8toUTF16(mCookieData.name()));

      nsString size;
      size.AppendInt(kMaxBytesPerCookie);
      aParams.AppendElement(size);
      return;
    }

    case eRejectedInvalidCharName: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedInvalidCharName"_ns);
      return;
    }

    case eRejectedInvalidCharValue: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedInvalidCharValue"_ns);
      return;
    }

    case eRejectedAttributePathOversize: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedAttributePathOversize"_ns);
      return;
    }

    case eRejectedAttributeDomainOversize: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedAttributeDomainOversize"_ns);
      return;
    }

    case eRejectedAttributeExpiryOversize: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedAttributeExpiryOversize"_ns);
      return;
    }

    case eRejectedInvalidPath: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY, "CookieRejectedInvalidPath"_ns);
      return;
    }

    case eRejectedInvalidDomain: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedInvalidDomain"_ns);
      return;
    }

    case eRejectedInvalidPrefix: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedInvalidPrefix"_ns);
      return;
    }

    case eRejectedHttpOnlyButFromScript: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedHttpOnlyButFromScript"_ns);
      return;
    }

    case eRejectedSecureButNonHttps: {
      SET_LOG_DATA(CONSOLE_REJECTION_CATEGORY,
                   "CookieRejectedSecureButNonHttps"_ns);
      return;
    }

    case eRejectedForNonSameSiteness: {
      SET_LOG_DATA(CONSOLE_SAMESITE_CATEGORY,
                   "CookieRejectedForNonSameSiteness"_ns);
      return;
    }
  }

#undef SET_LOG_DATA
}

void CookieValidation::ReportErrorsAndWarnings(nsIConsoleReportCollector* aCRC,
                                               nsIURI* aHostURI) const {
  if (mResult != eOK) {
    uint32_t flags;
    nsAutoCString category;
    nsAutoCString key;
    nsTArray<nsString> params;

    RetrieveErrorLogData(&flags, category, key, params);

    CookieLogging::LogMessageToConsole(aCRC, aHostURI, flags, category, key,
                                       params);
    return;
  }

  if (mWarnings.mSameSiteNoneRequiresSecureForBeta) {
    CookieLogging::LogMessageToConsole(
        aCRC, aHostURI, nsIScriptError::warningFlag, CONSOLE_SAMESITE_CATEGORY,
        "CookieRejectedNonRequiresSecureForBeta3"_ns,
        AutoTArray<nsString, 2>{NS_ConvertUTF8toUTF16(mCookieData.name()),
                                SAMESITE_MDN_URL});
  }

  if (mWarnings.mSameSiteLaxForced) {
    CookieLogging::LogMessageToConsole(
        aCRC, aHostURI, nsIScriptError::infoFlag, CONSOLE_SAMESITE_CATEGORY,
        "CookieLaxForced2"_ns,
        AutoTArray<nsString, 1>{NS_ConvertUTF8toUTF16(mCookieData.name())});
  }

  if (mWarnings.mSameSiteLaxForcedForBeta) {
    CookieLogging::LogMessageToConsole(
        aCRC, aHostURI, nsIScriptError::warningFlag, CONSOLE_SAMESITE_CATEGORY,
        "CookieLaxForcedForBeta2"_ns,
        AutoTArray<nsString, 2>{NS_ConvertUTF8toUTF16(mCookieData.name()),
                                SAMESITE_MDN_URL});
  }
}

NS_IMETHODIMP
CookieValidation::GetErrorString(nsAString& aResult) {
  if (mResult == eOK) {
    return NS_OK;
  }

  uint32_t flags;
  nsAutoCString category;
  nsAutoCString key;
  nsTArray<nsString> params;

  RetrieveErrorLogData(&flags, category, key, params);

  return nsContentUtils::FormatLocalizedString(
      PropertiesFile::NECKO_PROPERTIES_en_US, key.get(), params, aResult);
}

// static
bool CookieValidation::CheckNameAndValueSize(const CookieStruct& aCookieData) {
  // reject cookie if it's over the size limit, per RFC2109
  return (aCookieData.name().Length() + aCookieData.value().Length()) <=
         kMaxBytesPerCookie;
}

bool CookieValidation::CheckName(const CookieStruct& aCookieData) {
  if (!aCookieData.name().IsEmpty() && (aCookieData.name().First() == 0x20 ||
                                        aCookieData.name().Last() == 0x20)) {
    return false;
  }

  if (StaticPrefs::network_cookie_valueless_cookie() &&
      aCookieData.name().IsEmpty()) {
    return false;
  }

  const char illegalNameCharacters[] = {
      0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0A, 0x0B, 0x0C, 0x0D,
      0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19,
      0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x3B, 0x3D, 0x7F, 0x00};

  const auto* start = aCookieData.name().BeginReading();
  const auto* end = aCookieData.name().EndReading();

  auto charFilter = [&](unsigned char c) {
    if (StaticPrefs::network_cookie_blockUnicode() && c >= 0x80) {
      return true;
    }
    return std::find(std::begin(illegalNameCharacters),
                     std::end(illegalNameCharacters),
                     c) != std::end(illegalNameCharacters);
  };

  return std::find_if(start, end, charFilter) == end;
}

bool CookieValidation::CheckValue(const CookieStruct& aCookieData) {
  if (!aCookieData.value().IsEmpty() && (aCookieData.value().First() == 0x20 ||
                                         aCookieData.value().Last() == 0x20)) {
    return false;
  }

  // reject cookie if value contains an RFC 6265 disallowed character - see
  // https://bugzilla.mozilla.org/show_bug.cgi?id=1191423
  // NOTE: this is not the full set of characters disallowed by 6265 - notably
  // 0x09, 0x20, 0x22, 0x2C, and 0x5C are missing from this list.
  const char illegalCharacters[] = {
      0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0A, 0x0B, 0x0C,
      0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
      0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x3B, 0x7F, 0x00};

  const auto* start = aCookieData.value().BeginReading();
  const auto* end = aCookieData.value().EndReading();

  bool shouldBlockEqualInNamelessCookie =
      aCookieData.name().IsEmpty() &&
      StaticPrefs::network_cookie_block_nameless_with_equal_char();

  auto charFilter = [&](unsigned char c) {
    if (StaticPrefs::network_cookie_blockUnicode() && c >= 0x80) {
      return true;
    }

    if (c == '=' && shouldBlockEqualInNamelessCookie) {
      return true;
    }

    return std::find(std::begin(illegalCharacters), std::end(illegalCharacters),
                     c) != std::end(illegalCharacters);
  };

  return std::find_if(start, end, charFilter) == end;
}
