From 574c33c1c92457d896399acc7969d8ab4b91802a Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 9 Sep 2026 11:54:43 -0400 Subject: [PATCH 1/5] fix(network): use strict date parsing in WebdavUtils Replace the legacy multi-format SimpleDateFormat parser with thread-local, non-lenient parsers for current Nextcloud HTTP and Share API date formats. Require complete input consumption and interpret HTTP dates in UTC while preserving the existing millisecond-based date model. Signed-off-by: Josh --- .../lib/common/network/WebdavUtils.java | 83 +++++++++++++------ 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java b/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java index 338d7fafdb..f76d3d88de 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java +++ b/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java @@ -24,43 +24,76 @@ import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; import org.apache.jackrabbit.webdav.xml.Namespace; -import java.text.ParseException; +import java.text.ParsePosition; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; +import java.util.TimeZone; import androidx.annotation.Nullable; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @SuppressFBWarnings("FS") public class WebdavUtils { - private static final SimpleDateFormat DATETIME_FORMATS[] = { - new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US), - new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US), - new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.sss'Z'", Locale.US), - new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US), - new SimpleDateFormat("EEE MMM dd HH:mm:ss zzz yyyy", Locale.US), - new SimpleDateFormat("EEEEEE, dd-MMM-yy HH:mm:ss zzz", Locale.US), - new SimpleDateFormat("EEE MMMM d HH:mm:ss yyyy", Locale.US), - new SimpleDateFormat("yyyy-MM-dd hh:mm:ss", Locale.US) - }; + private static final String HTTP_DATE_FORMAT = + "EEE, dd MMM yyyy HH:mm:ss zzz"; + + private static final String SHARE_EXPIRATION_DATE_FORMAT = + "yyyy-MM-dd HH:mm:ss"; + + private static final TimeZone UTC = + TimeZone.getTimeZone("UTC"); + + private static final ThreadLocal HTTP_DATE_FORMATTER = + ThreadLocal.withInitial(() -> { + SimpleDateFormat format = + new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US); + format.setLenient(false); + format.setTimeZone(UTC); + return format; + }); + + private static final ThreadLocal SHARE_EXPIRATION_FORMATTER = + ThreadLocal.withInitial(() -> { + SimpleDateFormat format = + new SimpleDateFormat(SHARE_EXPIRATION_DATE_FORMAT, Locale.US); + format.setLenient(false); + return format; + }); + + private static @Nullable + Date parseStrict(SimpleDateFormat format, String value) { + if (value == null || value.isEmpty()) { + return null; + } + + ParsePosition position = new ParsePosition(0); + Date parsedDate = format.parse(value, position); + + if (parsedDate == null || position.getIndex() != value.length()) { + return null; + } + + return parsedDate; + } public static @Nullable Date parseResponseDate(String date) { - Date returnDate; - SimpleDateFormat format; - for (int i = 0; i < DATETIME_FORMATS.length; ++i) { - try { - format = DATETIME_FORMATS[i]; - synchronized (format) { - returnDate = format.parse(date); - } - return returnDate; - } catch (ParseException e) { - // this is not the format - } - } - return null; + return parseStrict(HTTP_DATE_FORMATTER.get(), date); + } + + /** + * Parses a Share API expiration date. + * + *

The value has no timezone suffix and is therefore interpreted using + * the device default timezone.

+ * + * @param date expiration date returned by the Share API + * @return parsed date, or {@code null} if invalid + */ + public static @Nullable + Date parseShareExpirationDate(String date) { + return parseStrict(SHARE_EXPIRATION_FORMATTER.get(), date); } /** From 9415e9432beeb23b7901245a9ce29c875ea7bdee Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 9 Sep 2026 11:57:34 -0400 Subject: [PATCH 2/5] chore(shares): switch to parseShareExpirationDate Signed-off-by: Josh --- .../owncloud/android/lib/resources/shares/ShareXMLParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/src/main/java/com/owncloud/android/lib/resources/shares/ShareXMLParser.java b/library/src/main/java/com/owncloud/android/lib/resources/shares/ShareXMLParser.java index 70c8de62f0..86fef8be56 100644 --- a/library/src/main/java/com/owncloud/android/lib/resources/shares/ShareXMLParser.java +++ b/library/src/main/java/com/owncloud/android/lib/resources/shares/ShareXMLParser.java @@ -348,7 +348,7 @@ private void readElement(XmlPullParser parser, ArrayList shares) case NODE_EXPIRATION: String expirationValue = readNode(parser, NODE_EXPIRATION); if (expirationValue.length() > 0) { - Date date = WebdavUtils.parseResponseDate(expirationValue); + Date date = WebdavUtils.parseShareExpirationDate(expirationValue); if (date != null) { share.setExpirationDate(date.getTime()); } From fc02afb288e7c08be75066237caca5bda91cba5a Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 10 Sep 2026 10:21:38 -0400 Subject: [PATCH 3/5] fix(network): modernize Nextcloud date parsing Replace legacy SimpleDateFormat parsing with strict, thread-safe DateTimeFormatter instances for current WebDAV and Share API date formats. Signed-off-by: Josh --- .../lib/common/network/WebdavUtils.java | 94 +++++++++---------- 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java b/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java index f76d3d88de..46fc743183 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java +++ b/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java @@ -24,62 +24,45 @@ import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; import org.apache.jackrabbit.webdav.xml.Namespace; -import java.text.ParsePosition; -import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; import java.util.Date; import java.util.Locale; -import java.util.TimeZone; import androidx.annotation.Nullable; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @SuppressFBWarnings("FS") public class WebdavUtils { - private static final String HTTP_DATE_FORMAT = - "EEE, dd MMM yyyy HH:mm:ss zzz"; - - private static final String SHARE_EXPIRATION_DATE_FORMAT = - "yyyy-MM-dd HH:mm:ss"; - - private static final TimeZone UTC = - TimeZone.getTimeZone("UTC"); - - private static final ThreadLocal HTTP_DATE_FORMATTER = - ThreadLocal.withInitial(() -> { - SimpleDateFormat format = - new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US); - format.setLenient(false); - format.setTimeZone(UTC); - return format; - }); - - private static final ThreadLocal SHARE_EXPIRATION_FORMATTER = - ThreadLocal.withInitial(() -> { - SimpleDateFormat format = - new SimpleDateFormat(SHARE_EXPIRATION_DATE_FORMAT, Locale.US); - format.setLenient(false); - return format; - }); - - private static @Nullable - Date parseStrict(SimpleDateFormat format, String value) { - if (value == null || value.isEmpty()) { + private static final DateTimeFormatter HTTP_DATE_FORMATTER = + DateTimeFormatter.ofPattern( + "EEE, dd MMM uuuu HH:mm:ss zzz", // IMF-fixdate + Locale.US + ).withResolverStyle(ResolverStyle.STRICT); + + private static final DateTimeFormatter SHARE_EXPIRATION_FORMATTER = + DateTimeFormatter.ofPattern( + "uuuu-MM-dd HH:mm:ss", + Locale.US + ).withResolverStyle(ResolverStyle.STRICT); + + public static @Nullable + Date parseResponseDate(String date) { + if (date == null || date.isEmpty()) { return null; } - ParsePosition position = new ParsePosition(0); - Date parsedDate = format.parse(value, position); - - if (parsedDate == null || position.getIndex() != value.length()) { + try { + return Date.from( + ZonedDateTime.parse(date, HTTP_DATE_FORMATTER).toInstant() + ); + } catch (DateTimeParseException e) { return null; } - - return parsedDate; - } - - public static @Nullable - Date parseResponseDate(String date) { - return parseStrict(HTTP_DATE_FORMATTER.get(), date); } /** @@ -93,14 +76,29 @@ Date parseResponseDate(String date) { */ public static @Nullable Date parseShareExpirationDate(String date) { - return parseStrict(SHARE_EXPIRATION_FORMATTER.get(), date); + if (date == null || date.isEmpty()) { + return null; + } + + try { + LocalDateTime localDateTime = + LocalDateTime.parse(date, SHARE_EXPIRATION_FORMATTER); + + return Date.from( + localDateTime + .atZone(ZoneId.systemDefault()) + .toInstant() + ); + } catch (DateTimeParseException e) { + return null; + } } /** - * Encodes a path according to URI RFC 2396. - * + * Encodes a path according to URI RFC 2396. + * * If the received path doesn't start with "/", the method adds it. - * + * * @param remoteFilePath Path * @return Encoded path according to RFC 2396, always starting with "/" */ @@ -171,7 +169,7 @@ public static DavPropertyNameSet getAllPropSet() { public static DavPropertyNameSet getFilePropSet() { Namespace ocNamespace = Namespace.getNamespace(WebdavEntry.NAMESPACE_OC); Namespace ncNamespace = Namespace.getNamespace(WebdavEntry.NAMESPACE_NC); - + DavPropertyNameSet propSet = new DavPropertyNameSet(); propSet.add(DavPropertyName.DISPLAYNAME); propSet.add(DavPropertyName.GETCONTENTTYPE); From 64db837812db2e15e381e4fcfb579c248a1f04e7 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 10 Sep 2026 11:13:10 -0400 Subject: [PATCH 4/5] fix(network): support legacy HTTP date formats Add RFC 850 and ANSI C asctime() parsing to preserve compatibility with obsolete but valid HTTP-date representations, including the RFC-defined handling of two-digit years. Signed-off-by: Josh --- .../lib/common/network/WebdavUtils.java | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java b/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java index 46fc743183..7c88587f4f 100644 --- a/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java +++ b/library/src/main/java/com/owncloud/android/lib/common/network/WebdavUtils.java @@ -24,12 +24,16 @@ import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; import org.apache.jackrabbit.webdav.xml.Namespace; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; import java.time.format.ResolverStyle; +import java.time.temporal.ChronoField; import java.util.Date; import java.util.Locale; @@ -39,17 +43,38 @@ @SuppressFBWarnings("FS") public class WebdavUtils { private static final DateTimeFormatter HTTP_DATE_FORMATTER = - DateTimeFormatter.ofPattern( - "EEE, dd MMM uuuu HH:mm:ss zzz", // IMF-fixdate - Locale.US - ).withResolverStyle(ResolverStyle.STRICT); + DateTimeFormatter + .ofPattern("EEE, dd MMM uuuu HH:mm:ss zzz", Locale.US) + .withResolverStyle(ResolverStyle.STRICT); + + private static final DateTimeFormatter RFC_850_DATE_FORMATTER = + new DateTimeFormatterBuilder() + .parseCaseSensitive() + .appendPattern("EEEE, dd-MMM-") + .appendValueReduced(ChronoField.YEAR, 2, 2, 2000) + .appendPattern(" HH:mm:ss zzz") + .toFormatter(Locale.US) + .withResolverStyle(ResolverStyle.STRICT); + + private static final DateTimeFormatter ASCTIME_DATE_FORMATTER = + DateTimeFormatter + .ofPattern("EEE MMM ppd HH:mm:ss uuuu", Locale.US) + .withResolverStyle(ResolverStyle.STRICT); private static final DateTimeFormatter SHARE_EXPIRATION_FORMATTER = - DateTimeFormatter.ofPattern( - "uuuu-MM-dd HH:mm:ss", - Locale.US - ).withResolverStyle(ResolverStyle.STRICT); + DateTimeFormatter + .ofPattern("uuuu-MM-dd HH:mm:ss", Locale.US) + .withResolverStyle(ResolverStyle.STRICT); + /** + * Parses an HTTP/WebDAV date in IMF-fixdate format (99% of modern traffic). + * + * For full RFC complianace, also supports obsolete RFC 850 and ANSI C asctime() + * formats. + * + * @param date HTTP/WebDAV date + * @return parsed date, or {@code null} if invalid + */ public static @Nullable Date parseResponseDate(String date) { if (date == null || date.isEmpty()) { @@ -57,8 +82,39 @@ Date parseResponseDate(String date) { } try { + ZonedDateTime parsed = + ZonedDateTime.parse(date, HTTP_DATE_FORMATTER); + + return Date.from(parsed.toInstant()); + } catch (DateTimeParseException e) { + // Try RFC 850 below. + } + + try { + ZonedDateTime parsed = + ZonedDateTime.parse(date, RFC_850_DATE_FORMATTER); + + // Native date arithmetic protects the 50-year spec calculation from leap day drift + Instant fiftyYearsFromNow = + ZonedDateTime.now(ZoneOffset.UTC) + .plusYears(50) + .toInstant(); + + if (parsed.toInstant().isAfter(fiftyYearsFromNow)) { + parsed = parsed.minusYears(100); + } + + return Date.from(parsed.toInstant()); + } catch (DateTimeParseException e) { + // Try ANSI C asctime() below. + } + + try { + LocalDateTime parsed = + LocalDateTime.parse(date, ASCTIME_DATE_FORMATTER); + return Date.from( - ZonedDateTime.parse(date, HTTP_DATE_FORMATTER).toInstant() + parsed.atOffset(ZoneOffset.UTC).toInstant() ); } catch (DateTimeParseException e) { return null; @@ -81,13 +137,11 @@ Date parseShareExpirationDate(String date) { } try { - LocalDateTime localDateTime = + LocalDateTime parsed = LocalDateTime.parse(date, SHARE_EXPIRATION_FORMATTER); return Date.from( - localDateTime - .atZone(ZoneId.systemDefault()) - .toInstant() + parsed.atZone(ZoneId.systemDefault()).toInstant() ); } catch (DateTimeParseException e) { return null; @@ -169,7 +223,7 @@ public static DavPropertyNameSet getAllPropSet() { public static DavPropertyNameSet getFilePropSet() { Namespace ocNamespace = Namespace.getNamespace(WebdavEntry.NAMESPACE_OC); Namespace ncNamespace = Namespace.getNamespace(WebdavEntry.NAMESPACE_NC); - + DavPropertyNameSet propSet = new DavPropertyNameSet(); propSet.add(DavPropertyName.DISPLAYNAME); propSet.add(DavPropertyName.GETCONTENTTYPE); From 7d0aca7bff34b3ef6e89796f49c4342ff26a1695 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 10 Sep 2026 11:47:34 -0400 Subject: [PATCH 5/5] test(network): cover WebDAV and Share API date parsing Add unit tests for IMF-fixdate, RFC 850, and ANSI C asctime() parsing, including strict validation, null and empty inputs, and RFC 850 compatibility. Also verify dedicated Share API expiration parsing, 24-hour timestamps, invalid dates, and rejection of HTTP-date values. Assisted-by: Copilot:gpt-5.6-luna Signed-off-by: Josh --- .../lib/common/network/WebdavUtilsTest.java | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 library/src/test/java/com/owncloud/android/lib/common/network/WebdavUtilsTest.java diff --git a/library/src/test/java/com/owncloud/android/lib/common/network/WebdavUtilsTest.java b/library/src/test/java/com/owncloud/android/lib/common/network/WebdavUtilsTest.java new file mode 100644 index 0000000000..22b3cc6c62 --- /dev/null +++ b/library/src/test/java/com/owncloud/android/lib/common/network/WebdavUtilsTest.java @@ -0,0 +1,160 @@ +/* + * Nextcloud Android Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: MIT + */ +package com.owncloud.android.lib.common.network; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +public class WebdavUtilsTest { + + @Test + public void parseResponseDate_parsesImfFixdate() { + Date result = WebdavUtils.parseResponseDate( + "Wed, 09 Sep 2026 08:49:37 GMT" + ); + + assertNotNull(result); + assertEquals( + Instant.parse("2026-09-09T08:49:37Z"), + result.toInstant() + ); + } + + @Test + public void parseResponseDate_parsesRfc850Date() { + Date result = WebdavUtils.parseResponseDate( + "Sunday, 06-Nov-94 08:49:37 GMT" + ); + + assertNotNull(result); + assertEquals( + Instant.parse("1994-11-06T08:49:37Z"), + result.toInstant() + ); + } + + @Test + public void parseResponseDate_parsesAsctimeDateWithSingleDigitDay() { + Date result = WebdavUtils.parseResponseDate( + "Sun Nov 6 08:49:37 1994" + ); + + assertNotNull(result); + assertEquals( + Instant.parse("1994-11-06T08:49:37Z"), + result.toInstant() + ); + } + + @Test + public void parseResponseDate_parsesAsctimeDateWithTwoDigitDay() { + Date result = WebdavUtils.parseResponseDate( + "Sun Nov 16 08:49:37 1994" + ); + + assertNotNull(result); + assertEquals( + Instant.parse("1994-11-16T08:49:37Z"), + result.toInstant() + ); + } + + @Test + public void parseResponseDate_rejectsInvalidWeekday() { + Date result = WebdavUtils.parseResponseDate( + "Monday, 06-Nov-94 08:49:37 GMT" + ); + + assertNull(result); + } + + @Test + public void parseResponseDate_rejectsInvalidDate() { + Date result = WebdavUtils.parseResponseDate( + "Wed, 31 Feb 2026 08:49:37 GMT" + ); + + assertNull(result); + } + + @Test + public void parseResponseDate_rejectsTrailingCharacters() { + Date result = WebdavUtils.parseResponseDate( + "Wed, 09 Sep 2026 08:49:37 GMT trailing" + ); + + assertNull(result); + } + + @Test + public void parseResponseDate_returnsNullForNullAndEmptyInput() { + assertNull(WebdavUtils.parseResponseDate(null)); + assertNull(WebdavUtils.parseResponseDate("")); + } + + @Test + public void parseShareExpirationDate_parsesShareApiFormat() { + String value = "2026-09-10 23:59:59"; + + Date result = WebdavUtils.parseShareExpirationDate(value); + + assertNotNull(result); + assertEquals( + LocalDateTime.parse(value) + .atZone(ZoneId.systemDefault()) + .toInstant(), + result.toInstant() + ); + } + + @Test + public void parseShareExpirationDate_parses24HourTime() { + String value = "2026-09-10 23:00:00"; + + Date result = WebdavUtils.parseShareExpirationDate(value); + + assertNotNull(result); + assertEquals( + LocalDateTime.parse(value) + .atZone(ZoneId.systemDefault()) + .toInstant(), + result.toInstant() + ); + } + + @Test + public void parseShareExpirationDate_rejectsInvalidDate() { + Date result = WebdavUtils.parseShareExpirationDate( + "2026-02-29 12:00:00" + ); + + assertNull(result); + } + + @Test + public void parseShareExpirationDate_rejectsHttpDate() { + Date result = WebdavUtils.parseShareExpirationDate( + "Wed, 09 Sep 2026 08:49:37 GMT" + ); + + assertNull(result); + } + + @Test + public void parseShareExpirationDate_returnsNullForNullAndEmptyInput() { + assertNull(WebdavUtils.parseShareExpirationDate(null)); + assertNull(WebdavUtils.parseShareExpirationDate("")); + } +}