mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-01-02 02:58:39 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5c68c6c6e | ||
|
|
58c39815e4 | ||
|
|
4b706f466f | ||
|
|
19f72b1386 |
@@ -2,7 +2,7 @@
|
||||
"name": "aliasvault-browser-extension",
|
||||
"description": "AliasVault Browser Extension",
|
||||
"private": true,
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:chrome": "wxt -b chrome",
|
||||
|
||||
@@ -460,7 +460,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -492,7 +492,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -530,7 +530,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -569,7 +569,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
@@ -6,7 +6,7 @@ export class AppInfo {
|
||||
/**
|
||||
* The current extension version. This should be updated with each release of the extension.
|
||||
*/
|
||||
public static readonly VERSION = '0.23.0';
|
||||
public static readonly VERSION = '0.23.1';
|
||||
|
||||
/**
|
||||
* The minimum supported AliasVault server (API) version. If the server version is below this, the
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
return {
|
||||
name: "AliasVault",
|
||||
description: "AliasVault Browser AutoFill Extension. Keeping your personal information private.",
|
||||
version: "0.23.0",
|
||||
version: "0.23.1",
|
||||
content_security_policy: {
|
||||
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
|
||||
@@ -94,7 +94,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 230000
|
||||
versionName "0.23.0"
|
||||
versionName "0.23.1"
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "AliasVault",
|
||||
"slug": "AliasVault",
|
||||
"version": "0.23.0",
|
||||
"version": "0.23.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "net.aliasvault.app",
|
||||
|
||||
@@ -1218,7 +1218,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -1253,7 +1253,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -1863,7 +1863,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
@@ -1908,7 +1908,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 0.23.0;
|
||||
MARKETING_VERSION = 0.23.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.aliasvault.app.autofill;
|
||||
|
||||
@@ -8,7 +8,7 @@ export class AppInfo {
|
||||
/**
|
||||
* The current mobile app version. This should be updated with each release of the mobile app.
|
||||
*/
|
||||
public static readonly VERSION = '0.23.0';
|
||||
public static readonly VERSION = '0.23.1';
|
||||
|
||||
/**
|
||||
* The minimum supported AliasVault server (API) version. If the server version is below this, the
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
<ProjectReference Include="..\Shared\AliasVault.Shared\AliasVault.Shared.csproj" />
|
||||
<ProjectReference Include="..\Utilities\Cryptography\AliasVault.Cryptography.Client\AliasVault.Cryptography.Client.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.ImportExport\AliasVault.ImportExport.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.FaviconExtractor\AliasVault.FaviconExtractor.csproj" />
|
||||
<ProjectReference Include="..\Utilities\AliasVault.TotpGenerator\AliasVault.TotpGenerator.csproj" />
|
||||
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
|
||||
<ServiceWorker Include="wwwroot\service-worker.published.js">
|
||||
|
||||
@@ -30,7 +30,7 @@ public static class AppInfo
|
||||
/// <summary>
|
||||
/// Gets the patch version number.
|
||||
/// </summary>
|
||||
public const int VersionPatch = 0;
|
||||
public const int VersionPatch = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum supported AliasVault client version. Normally the minimum client version is the same
|
||||
|
||||
@@ -22,4 +22,73 @@ public class FaviconExtractorTests
|
||||
var faviconBytes = await FaviconExtractor.FaviconExtractor.GetFaviconAsync("https://adsense.google.com/start/");
|
||||
Assert.That(faviconBytes, Is.Not.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that localhost URLs are blocked (SSRF protection).
|
||||
/// </summary>
|
||||
/// <returns>Task.</returns>
|
||||
[Test]
|
||||
public async Task BlockLocalhostUrls()
|
||||
{
|
||||
var localhostUrls = new[]
|
||||
{
|
||||
"http://localhost/favicon.ico",
|
||||
"http://127.0.0.1/favicon.ico",
|
||||
"http://[::1]/favicon.ico",
|
||||
"http://localhost:8080/favicon.ico",
|
||||
"https://localhost/favicon.ico",
|
||||
};
|
||||
|
||||
foreach (var url in localhostUrls)
|
||||
{
|
||||
var faviconBytes = await FaviconExtractor.FaviconExtractor.GetFaviconAsync(url);
|
||||
Assert.That(faviconBytes, Is.Null, $"Should block localhost URL: {url}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that private IP ranges are blocked (SSRF protection).
|
||||
/// </summary>
|
||||
/// <returns>Task.</returns>
|
||||
[Test]
|
||||
public async Task BlockPrivateIpRanges()
|
||||
{
|
||||
var privateIpUrls = new[]
|
||||
{
|
||||
"http://10.0.0.1/favicon.ico",
|
||||
"http://10.100.0.1/favicon.ico",
|
||||
"http://192.168.1.1/favicon.ico",
|
||||
"http://172.16.0.1/favicon.ico",
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS metadata endpoint
|
||||
"http://[fc00::1]/favicon.ico", // IPv6 private
|
||||
"http://[fe80::1]/favicon.ico", // IPv6 link-local
|
||||
};
|
||||
|
||||
foreach (var url in privateIpUrls)
|
||||
{
|
||||
var faviconBytes = await FaviconExtractor.FaviconExtractor.GetFaviconAsync(url);
|
||||
Assert.That(faviconBytes, Is.Null, $"Should block private IP URL: {url}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that non-standard ports are blocked.
|
||||
/// </summary>
|
||||
/// <returns>Task.</returns>
|
||||
[Test]
|
||||
public async Task BlockNonStandardPorts()
|
||||
{
|
||||
var nonStandardPortUrls = new[]
|
||||
{
|
||||
"http://example.com:8080/favicon.ico",
|
||||
"https://example.com:8443/favicon.ico",
|
||||
"http://example.com:3000/favicon.ico",
|
||||
};
|
||||
|
||||
foreach (var url in nonStandardPortUrls)
|
||||
{
|
||||
var faviconBytes = await FaviconExtractor.FaviconExtractor.GetFaviconAsync(url);
|
||||
Assert.That(faviconBytes, Is.Null, $"Should block non-standard port URL: {url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace AliasVault.FaviconExtractor;
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using HtmlAgilityPack;
|
||||
using SkiaSharp;
|
||||
@@ -24,7 +26,7 @@ public static class FaviconExtractor
|
||||
private static readonly string[] _allowedSchemes = { "http", "https" };
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the favicon from a URL.
|
||||
/// Extracts the favicon from a URL with enhanced browser like behavior.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to extract the favicon for.</param>
|
||||
/// <returns>Byte array for favicon image.</returns>
|
||||
@@ -40,63 +42,37 @@ public static class FaviconExtractor
|
||||
|
||||
using HttpClient client = CreateHttpClient();
|
||||
|
||||
// First attempt
|
||||
var result = await TryGetFaviconAsync(client, uri);
|
||||
if (result != null)
|
||||
// Attempt the operation up to two times to handle common cookiewall redirects or transient issues.
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
return result;
|
||||
var result = await TryGetFaviconAsync(client, uri);
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return await TryGetFaviconAsync(client, uri);
|
||||
// Return null if the favicon extraction failed.
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the URL by adding a scheme if it is missing.
|
||||
/// Tries to get the favicon from the URL.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to normalize.</param>
|
||||
/// <returns>The normalized URL.</returns>
|
||||
private static string NormalizeUrl(string url)
|
||||
/// <param name="client">The HTTP client.</param>
|
||||
/// <param name="uri">The URI to get the favicon from.</param>
|
||||
/// <returns>The favicon bytes.</returns>
|
||||
private static async Task<byte[]?> TryGetFaviconAsync(HttpClient client, Uri uri)
|
||||
{
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
var response = await FollowRedirectsAsync(client, uri);
|
||||
|
||||
if (response == null || !response.IsSuccessStatusCode)
|
||||
{
|
||||
return "https://" + url;
|
||||
return null;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the URI is valid.
|
||||
/// </summary>
|
||||
/// <param name="uri">The URI to check.</param>
|
||||
/// <returns>True if the URI is valid, false otherwise.</returns>
|
||||
private static bool IsValidUri(Uri uri)
|
||||
{
|
||||
return _allowedSchemes.Contains(uri.Scheme) && uri.IsDefaultPort;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new HTTP client with default headers.
|
||||
/// </summary>
|
||||
/// <returns>The HTTP client.</returns>
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 10,
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
|
||||
client.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
|
||||
client.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.5");
|
||||
client.DefaultRequestHeaders.Add("Connection", "keep-alive");
|
||||
client.DefaultRequestHeaders.Add("Upgrade-Insecure-Requests", "1");
|
||||
|
||||
return client;
|
||||
var faviconNodes = await GetFaviconNodesFromHtml(response, uri);
|
||||
return await TryExtractFaviconFromNodes(faviconNodes, client, uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -114,6 +90,7 @@ public static class FaviconExtractor
|
||||
var defaultFavicon = new HtmlNode(HtmlNodeType.Element, htmlDoc, 0);
|
||||
defaultFavicon.Attributes.Add("href", $"{uri.GetLeftPart(UriPartial.Authority)}/favicon.ico");
|
||||
|
||||
// Get the favicon nodes from the HTML, in order of preference.
|
||||
HtmlNodeCollection?[] nodeArray =
|
||||
[
|
||||
htmlDoc.DocumentNode.SelectNodes("//link[@rel='icon' and @type='image/svg+xml']"),
|
||||
@@ -131,18 +108,13 @@ public static class FaviconExtractor
|
||||
return nodeArray.Where(x => x != null).Cast<HtmlNodeCollection>().ToArray();
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> TryGetFaviconAsync(HttpClient client, Uri uri)
|
||||
{
|
||||
HttpResponseMessage response = await client.GetAsync(uri);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var faviconNodes = await GetFaviconNodesFromHtml(response, uri);
|
||||
return await TryExtractFaviconFromNodes(faviconNodes, client, uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to extract the favicon from the nodes.
|
||||
/// </summary>
|
||||
/// <param name="faviconNodes">The favicon nodes.</param>
|
||||
/// <param name="client">The HTTP client.</param>
|
||||
/// <param name="baseUri">The base URI.</param>
|
||||
/// <returns>The favicon bytes.</returns>
|
||||
private static async Task<byte[]?> TryExtractFaviconFromNodes(HtmlNodeCollection[] faviconNodes, HttpClient client, Uri baseUri)
|
||||
{
|
||||
foreach (var nodeCollection in faviconNodes)
|
||||
@@ -186,8 +158,16 @@ public static class FaviconExtractor
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
// Validate the favicon URL before fetching
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var faviconUri) || !IsValidUri(faviconUri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Follow redirects with validation
|
||||
var response = await FollowRedirectsAsync(client, faviconUri);
|
||||
|
||||
if (response == null || !response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -223,6 +203,179 @@ public static class FaviconExtractor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new HTTP client with enhanced browser-like configuration to handle bot protection.
|
||||
/// </summary>
|
||||
/// <returns>The HTTP client.</returns>
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false, // Handle redirects manually
|
||||
UseCookies = true, // Enable cookie handling for session management
|
||||
CookieContainer = new System.Net.CookieContainer(),
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate | System.Net.DecompressionMethods.Brotli,
|
||||
};
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(5), // Keep original timeout
|
||||
};
|
||||
|
||||
var random = new Random();
|
||||
var userAgents = new[]
|
||||
{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
|
||||
};
|
||||
|
||||
// Use random User-Agent
|
||||
client.DefaultRequestHeaders.Add("User-Agent", userAgents[random.Next(userAgents.Length)]);
|
||||
|
||||
// More comprehensive Accept header with image types prioritized
|
||||
client.DefaultRequestHeaders.Add(
|
||||
"Accept",
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7");
|
||||
|
||||
// Additional browser-like headers
|
||||
client.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.9");
|
||||
client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br");
|
||||
client.DefaultRequestHeaders.Add("DNT", "1");
|
||||
client.DefaultRequestHeaders.Add("Upgrade-Insecure-Requests", "1");
|
||||
client.DefaultRequestHeaders.Add("Cache-Control", "max-age=0");
|
||||
|
||||
// Add Sec-Fetch headers to mimic modern browsers
|
||||
if (random.Next(2) == 0)
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Sec-Fetch-Dest", "document");
|
||||
client.DefaultRequestHeaders.Add("Sec-Fetch-Mode", "navigate");
|
||||
client.DefaultRequestHeaders.Add("Sec-Fetch-Site", "none");
|
||||
client.DefaultRequestHeaders.Add("Sec-Fetch-User", "?1");
|
||||
}
|
||||
|
||||
// Add Chrome-specific headers randomly
|
||||
if (random.Next(3) == 0)
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Sec-CH-UA", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"");
|
||||
client.DefaultRequestHeaders.Add("Sec-CH-UA-Mobile", "?0");
|
||||
client.DefaultRequestHeaders.Add("Sec-CH-UA-Platform", "\"Windows\"");
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the URL by adding a scheme if it is missing.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL to normalize.</param>
|
||||
/// <returns>The normalized URL.</returns>
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "https://" + url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the URI is valid and not pointing to internal/private IPs.
|
||||
/// </summary>
|
||||
/// <param name="uri">The URI to check.</param>
|
||||
/// <returns>True if the URI is valid and safe, false otherwise.</returns>
|
||||
private static bool IsValidUri(Uri uri)
|
||||
{
|
||||
// Check scheme and port
|
||||
if (!_allowedSchemes.Contains(uri.Scheme) || !uri.IsDefaultPort)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve hostname to IP and validate
|
||||
try
|
||||
{
|
||||
var addresses = Dns.GetHostAddresses(uri.Host);
|
||||
foreach (var address in addresses)
|
||||
{
|
||||
if (!IPAddressValidator.IsPublicIPAddress(address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If DNS resolution fails, block the request
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles HTTP redirects with validation to prevent SSRF attacks.
|
||||
/// </summary>
|
||||
/// <param name="client">The HTTP client.</param>
|
||||
/// <param name="uri">The initial URI to request.</param>
|
||||
/// <returns>The final HTTP response after following redirects, or null if blocked/failed.</returns>
|
||||
private static async Task<HttpResponseMessage?> FollowRedirectsAsync(HttpClient client, Uri uri)
|
||||
{
|
||||
var currentUri = uri;
|
||||
int redirectCount = 0;
|
||||
const int maxRedirects = 5;
|
||||
|
||||
while (redirectCount < maxRedirects)
|
||||
{
|
||||
// Create request with referer header to appear more browser-like
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, currentUri);
|
||||
if (redirectCount == 0)
|
||||
{
|
||||
// First request - add Google referer to appear like navigation
|
||||
request.Headers.Add("Referer", "https://www.google.com/");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Subsequent redirects - use original URL as referer
|
||||
request.Headers.Add("Referer", uri.ToString());
|
||||
}
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400)
|
||||
{
|
||||
var location = response.Headers.Location;
|
||||
if (location == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve relative URLs
|
||||
if (!location.IsAbsoluteUri)
|
||||
{
|
||||
location = new Uri(currentUri, location);
|
||||
}
|
||||
|
||||
// Validate the redirect target
|
||||
if (!IsValidUri(location))
|
||||
{
|
||||
return null; // Block redirect to internal IPs
|
||||
}
|
||||
|
||||
currentUri = location;
|
||||
redirectCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // Too many redirects
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resizes the image to the target width.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="IPAddressValidator.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.FaviconExtractor;
|
||||
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
/// <summary>
|
||||
/// Validates IP addresses for public accessibility to prevent SSRF attacks.
|
||||
/// </summary>
|
||||
internal static class IPAddressValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Private IPv4 blocks.
|
||||
/// </summary>
|
||||
private static readonly (byte[] Net, int Prefix)[] PrivateV4Blocks = new[]
|
||||
{
|
||||
(new byte[] { 10, 0, 0, 0 }, 8), // private
|
||||
(new byte[] { 172, 16, 0, 0 }, 12), // private
|
||||
(new byte[] { 192, 168, 0, 0 }, 16), // private
|
||||
(new byte[] { 169, 254, 0, 0 }, 16), // link-local
|
||||
(new byte[] { 100, 64, 0, 0 }, 10), // CGNAT
|
||||
(new byte[] { 192, 0, 0, 0 }, 24), // IETF Protocol Assignments
|
||||
(new byte[] { 192, 0, 2, 0 }, 24), // TEST-NET-1
|
||||
(new byte[] { 198, 18, 0, 0 }, 15), // benchmarking
|
||||
(new byte[] { 198, 51, 100, 0 }, 24), // TEST-NET-2
|
||||
(new byte[] { 203, 0, 113, 0 }, 24), // TEST-NET-3
|
||||
(new byte[] { 224, 0, 0, 0 }, 4), // multicast
|
||||
(new byte[] { 240, 0, 0, 0 }, 4), // reserved
|
||||
(new byte[] { 0, 0, 0, 0 }, 8), // local
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Private IPv6 blocks.
|
||||
/// </summary>
|
||||
private static readonly (byte[] Net, int Prefix)[] PrivateV6Blocks = new[]
|
||||
{
|
||||
(new byte[] { 0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 7), // ULA
|
||||
(new byte[] { 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 32), // documentation
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an IP address is public (routable on the internet).
|
||||
/// </summary>
|
||||
/// <param name="address">The IP address to check.</param>
|
||||
/// <returns>True if the IP is publicly routable, false otherwise.</returns>
|
||||
public static bool IsPublicIPAddress(IPAddress address)
|
||||
{
|
||||
if (address is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(address));
|
||||
}
|
||||
|
||||
// Normalize IPv4-mapped IPv6 addresses to IPv4 for simpler handling.
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
// Loopback / unspecified (0.0.0.0 or ::) are not public.
|
||||
if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.None) || address.Equals(IPAddress.IPv6None))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4 checks.
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
return IsPublicIPv4Address(address);
|
||||
}
|
||||
|
||||
// IPv6 checks.
|
||||
if (address.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return IsPublicIPv6Address(address);
|
||||
}
|
||||
|
||||
// Unknown family -> treat as non-public.
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an IPv4 address is public.
|
||||
/// </summary>
|
||||
/// <param name="address">The IPv4 address to check.</param>
|
||||
/// <returns>True if the IP is publicly routable, false otherwise.</returns>
|
||||
private static bool IsPublicIPv4Address(IPAddress address)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
|
||||
// Broadcast 255.255.255.255
|
||||
if (bytes[0] == 255 && bytes[1] == 255 && bytes[2] == 255 && bytes[3] == 255)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the IP address is in any of the private IPv4 block.
|
||||
foreach (var (net, prefix) in PrivateV4Blocks)
|
||||
{
|
||||
if (IsInPrefix(bytes, net, prefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an IPv6 address is public.
|
||||
/// </summary>
|
||||
/// <param name="address">The IPv6 address to check.</param>
|
||||
/// <returns>True if the IP is publicly routable, false otherwise.</returns>
|
||||
private static bool IsPublicIPv6Address(IPAddress address)
|
||||
{
|
||||
// Built-in flags for common non-routable addresses
|
||||
if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6Multicast)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytes = address.GetAddressBytes();
|
||||
|
||||
// Check if the IP address is in any of the private IPv6 block.
|
||||
foreach (var (net, prefix) in PrivateV6Blocks)
|
||||
{
|
||||
if (IsInPrefix(bytes, net, prefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an address is within a CIDR prefix.
|
||||
/// </summary>
|
||||
/// <param name="address">The address bytes to check.</param>
|
||||
/// <param name="network">The network prefix bytes.</param>
|
||||
/// <param name="prefixLength">The prefix length in bits.</param>
|
||||
/// <returns>True if the address is within the prefix, false otherwise.</returns>
|
||||
private static bool IsInPrefix(byte[] address, byte[] network, int prefixLength)
|
||||
{
|
||||
int fullBytes = prefixLength / 8;
|
||||
int remainingBits = prefixLength % 8;
|
||||
|
||||
for (int i = 0; i < fullBytes; i++)
|
||||
{
|
||||
if (address[i] != network[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingBits == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
int mask = 0xFF << (8 - remainingBits) & 0xFF;
|
||||
return (address[fullBytes] & mask) == (network[fullBytes] & mask);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,38 @@ needs_cert_regeneration() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to check if a string is an IP address (IPv4 or IPv6)
|
||||
is_ip_address() {
|
||||
local value="$1"
|
||||
|
||||
# Check for IPv4 pattern
|
||||
if echo "$value" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
|
||||
# Validate each octet is <= 255
|
||||
local valid=1
|
||||
# Use a simple approach to split the IP address
|
||||
local o1=$(echo "$value" | cut -d. -f1)
|
||||
local o2=$(echo "$value" | cut -d. -f2)
|
||||
local o3=$(echo "$value" | cut -d. -f3)
|
||||
local o4=$(echo "$value" | cut -d. -f4)
|
||||
for octet in "$o1" "$o2" "$o3" "$o4"; do
|
||||
if [ "$octet" -gt 255 ]; then
|
||||
valid=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$valid" -eq 1 ]; then
|
||||
return 0 # It's a valid IPv4
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for IPv6 pattern (simplified check)
|
||||
if echo "$value" | grep -qE '^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$|^::1$|^::$'; then
|
||||
return 0 # It's likely IPv6
|
||||
fi
|
||||
|
||||
return 1 # Not an IP address
|
||||
}
|
||||
|
||||
# Generate self-signed SSL certificate if not exists or hostname changed
|
||||
if needs_cert_regeneration; then
|
||||
echo "Generating new SSL certificate (10 years validity)..."
|
||||
@@ -49,12 +81,23 @@ if needs_cert_regeneration; then
|
||||
-out /etc/nginx/ssl/cert.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
|
||||
else
|
||||
# Generate certificate with the hostname and include localhost as SAN
|
||||
# Determine if the hostname is an IP address or a DNS name
|
||||
if is_ip_address "$HOSTNAME_VALUE"; then
|
||||
# It's an IP address - use IP: prefix in SAN
|
||||
SAN_ENTRY="IP:${HOSTNAME_VALUE}"
|
||||
echo "Detected IP address: ${HOSTNAME_VALUE}"
|
||||
else
|
||||
# It's a DNS name - use DNS: prefix in SAN
|
||||
SAN_ENTRY="DNS:${HOSTNAME_VALUE}"
|
||||
echo "Detected hostname: ${HOSTNAME_VALUE}"
|
||||
fi
|
||||
|
||||
# Generate certificate with the appropriate SAN entry
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout /etc/nginx/ssl/key.pem \
|
||||
-out /etc/nginx/ssl/cert.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=AliasVault/CN=${HOSTNAME_VALUE}" \
|
||||
-addext "subjectAltName=DNS:${HOSTNAME_VALUE},DNS:localhost,IP:127.0.0.1"
|
||||
-addext "subjectAltName=${SAN_ENTRY},DNS:localhost,IP:127.0.0.1"
|
||||
fi
|
||||
|
||||
# Set proper permissions
|
||||
|
||||
@@ -176,6 +176,38 @@ needs_cert_regeneration() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to check if a string is an IP address (IPv4 or IPv6)
|
||||
is_ip_address() {
|
||||
local value="$1"
|
||||
|
||||
# Check for IPv4 pattern
|
||||
if echo "$value" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
|
||||
# Validate each octet is <= 255
|
||||
local valid=1
|
||||
# Use a simple approach to split the IP address
|
||||
local o1=$(echo "$value" | cut -d. -f1)
|
||||
local o2=$(echo "$value" | cut -d. -f2)
|
||||
local o3=$(echo "$value" | cut -d. -f3)
|
||||
local o4=$(echo "$value" | cut -d. -f4)
|
||||
for octet in "$o1" "$o2" "$o3" "$o4"; do
|
||||
if [ "$octet" -gt 255 ]; then
|
||||
valid=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$valid" -eq 1 ]; then
|
||||
return 0 # It's a valid IPv4
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for IPv6 pattern (simplified check)
|
||||
if echo "$value" | grep -qE '^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$|^::1$|^::$'; then
|
||||
return 0 # It's likely IPv6
|
||||
fi
|
||||
|
||||
return 1 # Not an IP address
|
||||
}
|
||||
|
||||
# Generate SSL certificates if needed or hostname changed
|
||||
if needs_cert_regeneration; then
|
||||
log 0 ""
|
||||
@@ -199,19 +231,30 @@ if needs_cert_regeneration; then
|
||||
>/dev/null 2>&1
|
||||
fi
|
||||
else
|
||||
# Generate certificate with the hostname and include localhost as SAN
|
||||
# Determine if the hostname is an IP address or a DNS name
|
||||
if is_ip_address "$HOSTNAME_VALUE"; then
|
||||
# It's an IP address - use IP: prefix in SAN
|
||||
SAN_ENTRY="IP:${HOSTNAME_VALUE}"
|
||||
log 1 "[init] Detected IP address: ${HOSTNAME_VALUE}"
|
||||
else
|
||||
# It's a DNS name - use DNS: prefix in SAN
|
||||
SAN_ENTRY="DNS:${HOSTNAME_VALUE}"
|
||||
log 1 "[init] Detected hostname: ${HOSTNAME_VALUE}"
|
||||
fi
|
||||
|
||||
# Generate certificate with the appropriate SAN entry
|
||||
if [ "$VERBOSITY" -ge 2 ]; then
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout /certificates/ssl/key.pem \
|
||||
-out /certificates/ssl/cert.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=AliasVault/CN=${HOSTNAME_VALUE}" \
|
||||
-addext "subjectAltName=DNS:${HOSTNAME_VALUE},DNS:localhost,IP:127.0.0.1"
|
||||
-addext "subjectAltName=${SAN_ENTRY},DNS:localhost,IP:127.0.0.1"
|
||||
else
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout /certificates/ssl/key.pem \
|
||||
-out /certificates/ssl/cert.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=AliasVault/CN=${HOSTNAME_VALUE}" \
|
||||
-addext "subjectAltName=DNS:${HOSTNAME_VALUE},DNS:localhost,IP:127.0.0.1" \
|
||||
-addext "subjectAltName=${SAN_ENTRY},DNS:localhost,IP:127.0.0.1" \
|
||||
>/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user