diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp index ff65228aa4..e1b5601db2 100644 --- a/staticlibs/Android.bp +++ b/staticlibs/Android.bp @@ -35,6 +35,7 @@ java_library { name: "net-utils-device-common", srcs: [ "device/com/android/net/module/util/DeviceConfigUtils.java", + "device/com/android/net/module/util/DomainUtils.java", "device/com/android/net/module/util/FdEventsReader.java", "device/com/android/net/module/util/NetworkMonitorUtils.java", "device/com/android/net/module/util/PacketReader.java", diff --git a/staticlibs/device/com/android/net/module/util/DomainUtils.java b/staticlibs/device/com/android/net/module/util/DomainUtils.java new file mode 100644 index 0000000000..80e0b64bab --- /dev/null +++ b/staticlibs/device/com/android/net/module/util/DomainUtils.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2023 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.net.module.util; + +import android.util.ArrayMap; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.net.module.util.DnsPacketUtils.DnsRecordParser; + +import java.nio.BufferOverflowException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; + +/** + * Utilities for encoding/decoding the domain name or domain search list. + * + * @hide + */ +public final class DomainUtils { + private static final String TAG = "DomainUtils"; + private static final int MAX_OPTION_LEN = 255; + + @NonNull + private static String getSubstring(@NonNull final String string, @NonNull final String[] labels, + int index) { + int beginIndex = 0; + for (int i = 0; i < index; i++) { + beginIndex += labels[i].length() + 1; // include the dot + } + return string.substring(beginIndex); + } + + /** + * Encode the given single domain name to byte array, comply with RFC1035 section-3.1. + * + * @return null if the given domain string is invalid, otherwise, return a byte array + * wrapping the encoded domain, not including any padded octets, caller should + * pad zero octets at the end if needed. + */ + @Nullable + public static byte[] encode(@NonNull final String domain) { + if (!DnsRecordParser.isHostName(domain)) return null; + return encode(new String[]{ domain }, false /* compression */); + } + + /** + * Encode the given multiple domain names to byte array, comply with RFC1035 section-3.1 + * and section 4.1.4 (message compression) if enabled. + * + * @return Null if encode fails due to BufferOverflowException, otherwise, return a byte + * array wrapping the encoded domains, not including any padded octets, caller + * should pad zero octets at the end if needed. The byte array may be empty if + * the given domain strings are invalid. + */ + @Nullable + public static byte[] encode(@NonNull final String[] domains, boolean compression) { + try { + final ByteBuffer buffer = ByteBuffer.allocate(MAX_OPTION_LEN); + final ArrayMap offsetMap = new ArrayMap<>(); + for (int i = 0; i < domains.length; i++) { + if (!DnsRecordParser.isHostName(domains[i])) { + Log.e(TAG, "Skip invalid domain name " + domains[i]); + continue; + } + final String[] labels = domains[i].split("\\."); + for (int j = 0; j < labels.length; j++) { + if (compression) { + final String suffix = getSubstring(domains[i], labels, j); + if (offsetMap.containsKey(suffix)) { + int offsetOfSuffix = offsetMap.get(suffix); + offsetOfSuffix |= 0xC000; + buffer.putShort((short) offsetOfSuffix); + break; // unnecessary to put the compressed string into map + } else { + offsetMap.put(suffix, buffer.position()); + } + } + // encode the domain name string without compression when: + // - compression feature isn't enabled, + // - suffix does not match any string in the map. + final byte[] labelBytes = labels[j].getBytes(StandardCharsets.UTF_8); + buffer.put((byte) labelBytes.length); + buffer.put(labelBytes); + if (j == labels.length - 1) { + // Pad terminate label at the end of last label. + buffer.put((byte) 0); + } + } + } + buffer.flip(); + final byte[] out = new byte[buffer.limit()]; + buffer.get(out); + return out; + } catch (BufferOverflowException e) { + Log.e(TAG, "Fail to encode domain name and stop encoding", e); + return null; + } + } + + /** + * Decode domain name(s) from the given byteBuffer. Decode follows RFC1035 section 3.1 and + * section 4.1.4(message compression). + * + * @return domain name(s) string array with space separated, or empty string if decode fails. + */ + @NonNull + public static String[] decode(@NonNull final ByteBuffer buffer, boolean compression) { + final ArrayList domainList = new ArrayList<>(); + while (buffer.remaining() > 0) { + try { + // TODO: replace the recursion with loop in parseName and don't need to pass in the + // maxLabelCount parameter to prevent recursion from overflowing stack. + final String domain = DnsRecordParser.parseName(buffer, 0 /* depth */, + 15 /* maxLabelCount */, compression); + if (!DnsRecordParser.isHostName(domain)) continue; + domainList.add(domain); + } catch (BufferUnderflowException | DnsPacket.ParseException e) { + Log.e(TAG, "Fail to parse domain name and stop parsing", e); + break; + } + } + return domainList.toArray(new String[0]); + } +} diff --git a/staticlibs/framework/com/android/net/module/util/DnsPacketUtils.java b/staticlibs/framework/com/android/net/module/util/DnsPacketUtils.java index c47bfa0746..105d783765 100644 --- a/staticlibs/framework/com/android/net/module/util/DnsPacketUtils.java +++ b/staticlibs/framework/com/android/net/module/util/DnsPacketUtils.java @@ -128,6 +128,15 @@ public final class DnsPacketUtils { && !InetAddresses.isNumericAddress(hostName); } + /** + * Parses the domain / target name of a DNS record. + */ + public static String parseName(final ByteBuffer buf, int depth, + boolean isNameCompressionSupported) throws + BufferUnderflowException, DnsPacket.ParseException { + return parseName(buf, depth, MAXLABELCOUNT, isNameCompressionSupported); + } + /** * Parses the domain / target name of a DNS record. * @@ -136,10 +145,10 @@ public final class DnsPacketUtils { * record may or may not support Name Compression, depending on the record TYPE. Moreover, * even if Name Compression is supported, its usage is left to the implementation. */ - public static String parseName(ByteBuffer buf, int depth, + public static String parseName(final ByteBuffer buf, int depth, int maxLabelCount, boolean isNameCompressionSupported) throws BufferUnderflowException, DnsPacket.ParseException { - if (depth > MAXLABELCOUNT) { + if (depth > maxLabelCount) { throw new DnsPacket.ParseException("Failed to parse name, too many labels"); } final int len = Byte.toUnsignedInt(buf.get()); @@ -158,7 +167,8 @@ public final class DnsPacketUtils { "Parse compression name fail, invalid compression"); } buf.position(offset); - final String pointed = parseName(buf, depth + 1, isNameCompressionSupported); + final String pointed = parseName(buf, depth + 1, maxLabelCount, + isNameCompressionSupported); buf.position(oldPos); return pointed; } else { @@ -168,7 +178,8 @@ public final class DnsPacketUtils { if (head.length() > MAXLABELSIZE) { throw new DnsPacket.ParseException("Parse name fail, invalid label length"); } - final String tail = parseName(buf, depth + 1, isNameCompressionSupported); + final String tail = parseName(buf, depth + 1, maxLabelCount, + isNameCompressionSupported); return TextUtils.isEmpty(tail) ? head : head + "." + tail; } } diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/DomainUtilsTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/DomainUtilsTest.java new file mode 100644 index 0000000000..606ed5fb7b --- /dev/null +++ b/staticlibs/tests/unit/src/com/android/net/module/util/DomainUtilsTest.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2023 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.net.module.util; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import libcore.util.HexEncoding; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.nio.ByteBuffer; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class DomainUtilsTest { + @Test + public void testEncodeInvalidDomain() { + byte[] buffer = DomainUtils.encode(".google.com"); + assertNull(buffer); + + buffer = DomainUtils.encode("google.com."); + assertNull(buffer); + + buffer = DomainUtils.encode("-google.com"); + assertNull(buffer); + + buffer = DomainUtils.encode("google.com-"); + assertNull(buffer); + + buffer = DomainUtils.encode("google..com"); + assertNull(buffer); + + buffer = DomainUtils.encode("google!.com"); + assertNull(buffer); + + buffer = DomainUtils.encode("google.o"); + assertNull(buffer); + + buffer = DomainUtils.encode("google,com"); + assertNull(buffer); + } + + @Test + public void testEncodeValidDomainNamesWithoutCompression() { + // Single domain: "google.com" + String suffix = "06676F6F676C6503636F6D00"; + byte[] buffer = DomainUtils.encode("google.com"); + //assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // Single domain: "google-guest.com" + suffix = "0C676F6F676C652D677565737403636F6D00"; + buffer = DomainUtils.encode("google-guest.com"); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // domain search list: "example.corp.google.com", "corp.google.com", "google.com" + suffix = "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "04636F727006676F6F676C6503636F6D00" // corp.google.com + + "06676F6F676C6503636F6D00"; // google.com + buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "corp.google.com", "google.com"}, + false /* compression */); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + + // domain search list: "example.corp.google.com", "corp..google.com"(invalid domain), + // "google.com" + suffix = "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "06676F6F676C6503636F6D00"; // google.com + buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "corp..google.com", "google.com"}, + false /* compression */); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // Invalid domain search list: "corp..google.com", "..google.com" + buffer = DomainUtils.encode(new String[] {"corp..google.com", "..google.com"}, + false /* compression */); + assertEquals(0, buffer.length); + } + + @Test + public void testEncodeValidDomainNamesWithCompression() { + // domain search list: "example.corp.google.com", "corp.google.com", "google.com" + String suffix = + "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "C008" // corp.google.com + + "C00D"; // google.com + byte[] buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "corp.google.com", "google.com"}, true); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // domain search list: "example.corp.google.com", "a.example.corp.google.com", "google.com" + suffix = "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "0161C000" // a.example.corp.google.com + + "C00D"; // google.com + buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "a.example.corp.google.com", "google.com"}, true); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // domain search list: "example.corp.google.com", "google.com", "gle.com" + suffix = "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "C00D" // google.com + + "03676C65C014"; // gle.com + buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "google.com", "gle.com"}, true); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // domain search list: "example.corp.google.com", "google.com", "google" + suffix = "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "C00D"; // google.com + buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "google.com", "google"}, true); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // domain search list: "example.corp.google.com", "..google.com"(invalid domain), "google" + suffix = "076578616D706C6504636F727006676F6F676C6503636F6D00"; // example.corp.google.com + buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "..google.com", "google"}, true); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // domain search list: "example.corp.google.com", "suffix.example.edu.cn", "edu.cn" + suffix = "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "06737566666978076578616D706C650365647502636E00" // suffix.example.edu.cn + + "C028"; // edu.cn + buffer = DomainUtils.encode(new String[] { + "example.corp.google.com", "suffix.example.edu.cn", "edu.cn"}, true); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + + // domain search list: "google.com", "example.com", "sub.example.com" + suffix = "06676F6F676C6503636F6D00" // google.com + + "076578616D706C65C007" // example.com + + "03737562C00C"; // sub.example.com + buffer = DomainUtils.encode(new String[] { + "google.com", "example.com", "sub.example.com"}, true); + assertNotNull(buffer); + assertEquals(suffix, HexEncoding.encodeToString(buffer)); + } + + @Test + public void testDecodeDomainNames() { + String suffixes = "06676F6F676C6503636F6D00" // google.com + + "076578616D706C6503636F6D00" // example.com + + "06676F6F676C6500"; // google + String[] expected = new String[] {"google.com", "example.com"}; + ByteBuffer buffer = ByteBuffer.wrap(HexEncoding.decode(suffixes)); + String[] suffixString = DomainUtils.decode(buffer, false /* compression */); + assertArrayEquals(expected, suffixString); + + // include suffix with invalid length: 64 + suffixes = "06676F6F676C6503636F6D00" // google.com + + "406578616D706C6503636F6D00" // example.com(length=64) + + "06676F6F676C6500"; // google + expected = new String[] {"google.com"}; + buffer = ByteBuffer.wrap(HexEncoding.decode(suffixes)); + suffixString = DomainUtils.decode(buffer, false /* compression */); + assertArrayEquals(expected, suffixString); + + // include suffix with invalid length: 0 + suffixes = "06676F6F676C6503636F6D00" // google.com + + "076578616D706C6503636F6D00" // example.com + + "00676F6F676C6500"; // google(length=0) + expected = new String[] {"google.com", "example.com"}; + buffer = ByteBuffer.wrap(HexEncoding.decode(suffixes)); + suffixString = DomainUtils.decode(buffer, false /* compression */); + assertArrayEquals(expected, suffixString); + + suffixes = + "076578616D706C6504636F727006676F6F676C6503636F6D00" // example.corp.google.com + + "C008" // corp.google.com + + "C00D"; // google.com + expected = new String[] {"example.corp.google.com", "corp.google.com", "google.com"}; + buffer = ByteBuffer.wrap(HexEncoding.decode(suffixes)); + suffixString = DomainUtils.decode(buffer, true /* compression */); + assertArrayEquals(expected, suffixString); + } +}