Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion belcard/src/belcard_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,16 @@ shared_ptr<BelCardGeneric> BelCardParser::_parse(const string &input, const stri
size_t parsedSize = 0;
shared_ptr<BelCardGeneric> ret = _parser->parseInput(rule, input, &parsedSize);
if (parsedSize < input.size()) {
bctbx_error("[BelCard] Parsing ended prematuraly at pos %llu", (unsigned long long)parsedSize);
// Find the line that caused the failure
size_t lineStart = (parsedSize > 0) ? input.rfind('\n', parsedSize - 1) : string::npos;
lineStart = (lineStart == string::npos) ? 0 : lineStart + 1;
size_t lineEnd = input.find('\n', parsedSize);
if (lineEnd == string::npos) lineEnd = input.size();
string failedLine = input.substr(lineStart, lineEnd - lineStart);
// Truncate very long lines (e.g. base64 PHOTO data) for logging
if (failedLine.size() > 200) failedLine = failedLine.substr(0, 200) + "...(truncated)";
bctbx_error("[BelCard] Parsing ended prematurely at pos %llu, failed at line: '%s'",
(unsigned long long)parsedSize, failedLine.c_str());
}
return ret;
}
Expand Down
24 changes: 21 additions & 3 deletions liblinphone/src/friend/friend-list.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,15 @@ std::shared_ptr<Friend> FriendList::findFriendByAddress(const std::shared_ptr<co
cleanUri = address->asStringUriOnly();
}
std::shared_ptr<Friend> lf = findFriendByUri(cleanUri);
if (lf) return lf;

// Fallback: if the username looks like a phone number, try matching with normalization.
// This handles cases where the caller uses local format (e.g. 0123456789) but the
// CardDAV contact stores the number in international format (e.g. +49123456789 or 0049123456789).
const std::string username = address->getUsername();
if (!username.empty() && linphone_account_is_phone_number(nullptr, username.c_str())) {
lf = findFriendByPhoneNumber(username);
}
return lf;
}

Expand Down Expand Up @@ -288,14 +297,23 @@ std::shared_ptr<Friend> FriendList::findFriendByUri(const std::string &uri) cons

std::list<std::shared_ptr<Friend>>
FriendList::findFriendsByAddress(const std::shared_ptr<const Address> &address) const {
std::list<std::shared_ptr<Friend>> result;
if (address->hasUriParam("gr")) {
std::shared_ptr<Address> cleanAddress = address->clone()->toSharedPtr();
cleanAddress->removeUriParam("gr");
std::list<std::shared_ptr<Friend>> result = findFriendsByUri(cleanAddress->asStringUriOnly());
return result;
result = findFriendsByUri(cleanAddress->asStringUriOnly());
} else {
result = findFriendsByUri(address->asStringUriOnly());
}

std::list<std::shared_ptr<Friend>> result = findFriendsByUri(address->asStringUriOnly());
// Fallback: if the username looks like a phone number, try matching with normalization.
if (result.empty()) {
const std::string username = address->getUsername();
if (!username.empty() && linphone_account_is_phone_number(nullptr, username.c_str())) {
std::shared_ptr<Friend> lf = findFriendByPhoneNumber(username);
if (lf) result.push_back(lf);
}
}
return result;
}

Expand Down
47 changes: 47 additions & 0 deletions liblinphone/src/vcard/carddav-context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
#include "vcard.h"
#include "xml/xml-parsing-context.h"

#include "bctoolbox/crypto.h"
#include "private.h"

// =============================================================================
Expand Down Expand Up @@ -365,6 +366,52 @@ void CardDAVContext::sendQuery(const shared_ptr<CardDAVQuery> &query, bool cance
return;
}

// Add preemptive Authorization: Basic header for CardDAV servers.
// The core's AuthInfo system clears plaintext passwords (replacing them with HA1),
// but HTTP Basic auth requires the plaintext password. CardDAV credentials are
// stored separately in the [carddav_auth] config section to avoid this issue.
{
belle_generic_uri_t *parsedUri = belle_generic_uri_parse(url.c_str());
if (parsedUri) {
const char *host = belle_generic_uri_get_host(parsedUri);
if (host) {
string domain(host);
LpConfig *lpconfig = linphone_core_get_config(getCore()->getCCore());
const char *cfgUsername = linphone_config_get_string(lpconfig, "carddav_auth",
(domain + "_username").c_str(), NULL);
const char *cfgPassword = linphone_config_get_string(lpconfig, "carddav_auth",
(domain + "_password").c_str(), NULL);

if (cfgUsername && cfgPassword) {
lInfo() << "[CardDAV] Found CardDAV credentials in config for domain [" << domain << "]";
mHttpRequest->setAuthInfo(cfgUsername, host);

string credentials = string(cfgUsername) + ":" + string(cfgPassword);
size_t encodedLen = credentials.size() * 2;
vector<unsigned char> encoded(encodedLen);
bctbx_base64_encode(encoded.data(), &encodedLen,
(const unsigned char *)credentials.c_str(), credentials.size());
string authHeader = "Basic " + string((char *)encoded.data(), encodedLen);
lInfo() << "[CardDAV] Adding preemptive Basic auth header for user [" << cfgUsername << "]";
mHttpRequest->addHeader("Authorization", authHeader);
} else {
// Fallback: try AuthInfo (password may be available if store_ha1_passwd is disabled)
const LinphoneAuthInfo *ai = linphone_core_find_auth_info(getCore()->getCCore(), NULL, NULL, host);
if (ai) {
const char *authUsername = linphone_auth_info_get_username(ai);
if (authUsername) {
lInfo() << "[CardDAV] Setting auth info from AuthInfo for domain [" << domain << "]";
mHttpRequest->setAuthInfo(authUsername, host);
}
} else {
lInfo() << "[CardDAV] No auth info found for domain [" << domain << "]";
}
}
}
belle_sip_object_unref(parsedUri);
}
}

if (!query->mDepth.empty()) {
mHttpRequest->addHeader("Depth", query->mDepth);
} else if (!query->mIfmatch.empty()) {
Expand Down
134 changes: 131 additions & 3 deletions liblinphone/src/vcard/vcard-context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
#include "vcard-context.h"
#include "vcard.h"

#include <sstream>

// =============================================================================

using namespace std;
Expand All @@ -44,21 +46,147 @@ VcardContext *VcardContext::clone() const {

// -----------------------------------------------------------------------------

static string fixVcardTimestamps(const string &input) {
// Nextcloud/Sabre generates REV timestamps without seconds (e.g. REV:20260221T2148Z)
// but RFC 6350 and belcard grammar require seconds (REV:20260221T214800Z).
// Fix by inserting "00" seconds before the trailing "Z" when only HHmm is present.
//
// NOTE: We avoid std::regex_replace here because the replacement pattern "$100$2"
// is misinterpreted by some C++ stdlib implementations (notably libc++ on macOS):
// "$10" is parsed as backreference to group 10 instead of group 1 + literal "0",
// producing "0Z" instead of "REV:20260221T222600Z".
string result = input;
size_t pos = 0;
while ((pos = result.find("REV:", pos)) != string::npos) {
// REV:<8 digits>T<4 digits>Z → insert "00" before Z
size_t valueStart = pos + 4;
size_t expectedZ = valueStart + 13; // 8 digits + T + 4 digits = 13
if (expectedZ < result.size() && result[valueStart + 8] == 'T' &&
(result[expectedZ] == 'Z' || result[expectedZ] == 'z')) {
bool valid = true;
for (size_t i = 0; i < 8 && valid; i++) valid = isdigit((unsigned char)result[valueStart + i]);
for (size_t i = 9; i < 13 && valid; i++) valid = isdigit((unsigned char)result[valueStart + i]);
if (valid) {
result.insert(expectedZ, "00");
pos = expectedZ + 3; // skip past inserted "00Z"
continue;
}
}
pos += 4;
}
return result;
}

// Sanitize vCard properties that are syntactically invalid per RFC 6350 / belcard grammar.
// A single invalid property line causes the entire vCard to fail parsing in belcard/belr
// because the strict PEG grammar's 1*property loop stops on the first non-matching line.
//
// Known problematic patterns from Nextcloud/Sabre/Google contacts:
// - URL;VALUE=URI:Google+ => value is not a valid URI (no scheme), remove the line
// - PHOTO;VALUE=URI:data:application/octet-stream;base64\, => empty data, remove the line
static string sanitizeVcardProperties(const string &input) {
istringstream stream(input);
string line;
string result;
result.reserve(input.size());
int removedCount = 0;

while (getline(stream, line)) {
// Remove trailing \r if present (getline strips \n but not \r)
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}

bool removeLine = false;

// Check URL properties: value must be a valid URI (must contain "://")
// Handles grouped properties like ITEM1.URL;VALUE=URI:...
{
string upper = line;
for (auto &c : upper) c = static_cast<char>(toupper((unsigned char)c));
// Find "URL" preceded by start-of-line or "."
size_t urlPos = upper.find("URL");
if (urlPos != string::npos && (urlPos == 0 || upper[urlPos - 1] == '.')) {
// Find the colon that separates property name/params from value
size_t colonPos = line.find(':', urlPos + 3);
if (colonPos != string::npos) {
string value = line.substr(colonPos + 1);
// A valid URI must have a scheme followed by ":" (e.g. "http:", "https:", "tel:")
if (value.find(':') == string::npos) {
lWarning() << "[vCard] Removing invalid URL property (value '" << value
<< "' is not a valid URI): " << line;
removeLine = true;
}
}
}
}

// Check PHOTO properties with empty data
// e.g. PHOTO;VALUE=URI:data:application/octet-stream;base64\,
if (!removeLine) {
string upper = line;
for (auto &c : upper) c = static_cast<char>(toupper((unsigned char)c));
size_t photoPos = upper.find("PHOTO");
if (photoPos != string::npos && (photoPos == 0 || upper[photoPos - 1] == '.')) {
// Check if it's a base64 data URI with empty data
size_t base64Pos = line.find("base64");
if (base64Pos == string::npos) base64Pos = line.find("BASE64");
if (base64Pos != string::npos) {
// Find the comma after base64 — data should follow
size_t commaPos = line.find(',', base64Pos);
if (commaPos == string::npos) commaPos = line.find("\\,", base64Pos);
if (commaPos != string::npos) {
string afterComma = line.substr(
line[commaPos] == '\\' ? commaPos + 2 : commaPos + 1);
// Trim whitespace
size_t start = afterComma.find_first_not_of(" \t");
if (start == string::npos || afterComma.substr(start).empty()) {
lWarning() << "[vCard] Removing PHOTO property with empty base64 data: "
<< line.substr(0, 80) << "...";
removeLine = true;
}
}
}
}
}

if (removeLine) {
removedCount++;
} else {
result += line + "\r\n";
}
}

if (removedCount > 0) {
lInfo() << "[vCard] Sanitized vCard: removed " << removedCount << " invalid property line(s)";
}

return result;
}

shared_ptr<Vcard> VcardContext::getVcardFromBuffer(const string &buffer) const {
if (buffer.empty()) return nullptr;
shared_ptr<belcard::BelCard> belCard = mParser->parseOne(buffer);
string fixedBuffer = fixVcardTimestamps(buffer);
if (fixedBuffer != buffer) {
lInfo() << "[vCard] Applied timestamp fix to vCard buffer";
}
fixedBuffer = sanitizeVcardProperties(fixedBuffer);
shared_ptr<belcard::BelCard> belCard = mParser->parseOne(fixedBuffer);
if (belCard) {
return Vcard::create(belCard);
} else {
lError() << "[vCard] Couldn't parse buffer " << buffer;
// Log the fixed buffer so we can see exactly what belcard failed to parse
lError() << "[vCard] Couldn't parse buffer (length=" << fixedBuffer.size() << "):\n" << fixedBuffer;
return nullptr;
}
}

list<shared_ptr<Vcard>> VcardContext::getVcardListFromBuffer(const string &buffer) const {
list<shared_ptr<Vcard>> result;
if (!buffer.empty()) {
shared_ptr<belcard::BelCardList> belCards = mParser->parse(buffer);
string fixedBuffer = fixVcardTimestamps(buffer);
fixedBuffer = sanitizeVcardProperties(fixedBuffer);
shared_ptr<belcard::BelCardList> belCards = mParser->parse(fixedBuffer);
if (belCards) {
for (const auto &belCard : belCards->getCards())
result.push_back(Vcard::create(belCard));
Expand Down