diff --git a/api/cache/redis.go b/api/cache/redis.go index 92fcab02..8e6cfa01 100644 --- a/api/cache/redis.go +++ b/api/cache/redis.go @@ -206,6 +206,18 @@ func (c *RedisCache) CreateOrUpdateProfileSettings(ctx context.Context, settings return err } + // add security rebinding protection settings + rebindingSettings := fmt.Sprintf("settings:%s:%s:%s", settings.ProfileId, "security", "rebinding_protection") + securityRebindingCmd := rdp.HSet(ctx, rebindingSettings, settings.Security.RebindingProtection) + if err := securityRebindingCmd.Err(); err != nil { + log.Err(err).Msg("Cache: failed to create security rebinding protection settings") + if rollback { + log.Warn().Msg("Cache: rolling back security rebinding protection settings") + rdp.Del(ctx, rebindingSettings) + } + return err + } + // add advanced settings advancedSettings := fmt.Sprintf("settings:%s:%s", settings.ProfileId, "advanced") advancedCmd := rdp.HSet(ctx, advancedSettings, settings.Advanced) diff --git a/api/docs/docs.go b/api/docs/docs.go index 251dcc36..aa54c856 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2982,6 +2982,7 @@ const docTemplate = `{ "/settings/privacy/custom_rules_subdomains_rule", "/settings/security/dnssec/enabled", "/settings/security/dnssec/send_do_bit", + "/settings/security/rebinding_protection/enabled", "/settings/advanced/recursor" ] }, @@ -3023,6 +3024,14 @@ const docTemplate = `{ } } }, + "model.RebindingProtection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "model.Retention": { "type": "string", "enum": [ @@ -3048,6 +3057,9 @@ const docTemplate = `{ "properties": { "dnssec": { "$ref": "#/definitions/model.DNSSECSettings" + }, + "rebinding_protection": { + "$ref": "#/definitions/model.RebindingProtection" } } }, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 7628f07a..210e2d86 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -2974,6 +2974,7 @@ "/settings/privacy/custom_rules_subdomains_rule", "/settings/security/dnssec/enabled", "/settings/security/dnssec/send_do_bit", + "/settings/security/rebinding_protection/enabled", "/settings/advanced/recursor" ] }, @@ -3015,6 +3016,14 @@ } } }, + "model.RebindingProtection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "model.Retention": { "type": "string", "enum": [ @@ -3040,6 +3049,9 @@ "properties": { "dnssec": { "$ref": "#/definitions/model.DNSSECSettings" + }, + "rebinding_protection": { + "$ref": "#/definitions/model.RebindingProtection" } } }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 64e63767..28dea80d 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -389,6 +389,7 @@ definitions: - /settings/privacy/custom_rules_subdomains_rule - /settings/security/dnssec/enabled - /settings/security/dnssec/send_do_bit + - /settings/security/rebinding_protection/enabled - /settings/advanced/recursor type: string value: {} @@ -420,6 +421,11 @@ definitions: timestamp: type: string type: object + model.RebindingProtection: + properties: + enabled: + type: boolean + type: object model.Retention: enum: - 1h @@ -438,6 +444,8 @@ definitions: properties: dnssec: $ref: '#/definitions/model.DNSSECSettings' + rebinding_protection: + $ref: '#/definitions/model.RebindingProtection' required: - dnssec type: object diff --git a/api/model/profile.go b/api/model/profile.go index 768baca4..ff7b4fc1 100644 --- a/api/model/profile.go +++ b/api/model/profile.go @@ -32,6 +32,6 @@ func NewProfile(idGen idgen.Generator, name, accountId string) (*Profile, error) // RFC6902 JSON Patch format is used type ProfileUpdate struct { Operation string `json:"operation" validate:"required,oneof=remove add replace move copy"` - Path string `json:"path" validate:"required,oneof=/name /settings/statistics/enabled /settings/logs/enabled /settings/logs/log_clients_ips /settings/logs/log_domains /settings/logs/retention /settings/privacy/default_rule /settings/privacy/blocklists_subdomains_rule /settings/privacy/custom_rules_subdomains_rule /settings/security/dnssec/enabled /settings/security/dnssec/send_do_bit /settings/advanced/recursor"` + Path string `json:"path" validate:"required,oneof=/name /settings/statistics/enabled /settings/logs/enabled /settings/logs/log_clients_ips /settings/logs/log_domains /settings/logs/retention /settings/privacy/default_rule /settings/privacy/blocklists_subdomains_rule /settings/privacy/custom_rules_subdomains_rule /settings/security/dnssec/enabled /settings/security/dnssec/send_do_bit /settings/security/rebinding_protection/enabled /settings/advanced/recursor"` Value any `json:"value" validate:"required"` } diff --git a/api/model/profile_settings.go b/api/model/profile_settings.go index b97c3504..f9e470b2 100644 --- a/api/model/profile_settings.go +++ b/api/model/profile_settings.go @@ -59,6 +59,9 @@ func NewSettings() *ProfileSettings { Enabled: true, SendDoBit: false, }, + RebindingProtection: RebindingProtection{ + Enabled: false, + }, }, Logs: &LogsSettings{ Enabled: false, diff --git a/api/model/security.go b/api/model/security.go index 8ff60ac7..7bc59ddf 100644 --- a/api/model/security.go +++ b/api/model/security.go @@ -2,10 +2,18 @@ package model // Security represents security settings type Security struct { - DNSSECSettings DNSSECSettings `json:"dnssec" bson:"dnssec" redis:"dnssec" binding:"required"` + DNSSECSettings DNSSECSettings `json:"dnssec" bson:"dnssec" redis:"dnssec" binding:"required"` + RebindingProtection RebindingProtection `json:"rebinding_protection" bson:"rebinding_protection" redis:"rebinding_protection"` } type DNSSECSettings struct { Enabled bool `json:"enabled" bson:"enabled" redis:"enabled" binding:"required"` SendDoBit bool `json:"send_do_bit" bson:"send_do_bit" redis:"send_do_bit" binding:"required"` } + +// RebindingProtection holds the per-profile DNS rebinding protection toggle. +// When enabled, the proxy blocks answers where a public name resolves to a +// private/loopback/link-local IP. Default off (opt-in). +type RebindingProtection struct { + Enabled bool `json:"enabled" bson:"enabled" redis:"enabled"` +} diff --git a/api/service/profile/service.go b/api/service/profile/service.go index 9d8f87ac..afeb1e7a 100644 --- a/api/service/profile/service.go +++ b/api/service/profile/service.go @@ -258,6 +258,12 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, accountId, profileId } } + if strings.Contains(update.Path, "/settings/security/rebinding_protection/") { + if err = p.handleRebindingProtectionUpdate(profile, update.Path, update); err != nil { + return nil, err + } + } + if strings.Contains(update.Path, "/settings/advanced/") { if err = p.handleAdvancedSettingsUpdate(profile, update.Path, update); err != nil { return nil, err @@ -501,6 +507,29 @@ func (p *ProfileService) handleDNSSECSettingsUpdate(profile *model.Profile, upda return nil } +func (p *ProfileService) handleRebindingProtectionUpdate(profile *model.Profile, updatePath string, update model.ProfileUpdate) error { + switch updatePath { // nolint + case "/settings/security/rebinding_protection/enabled": + return p.updateRebindingProtectionEnabled(profile, update) + } + + return nil +} + +func (p *ProfileService) updateRebindingProtectionEnabled(profile *model.Profile, update model.ProfileUpdate) (err error) { + var enabled bool + switch update.Operation { // nolint + case model.UpdateOperationReplace: + enabled, err = cast.ToBoolE(update.Value) + if err != nil { + return err + } + profile.Settings.Security.RebindingProtection.Enabled = enabled + } + + return nil +} + func (p *ProfileService) handleAdvancedSettingsUpdate(profile *model.Profile, updatePath string, update model.ProfileUpdate) error { switch updatePath { // nolint case "/settings/advanced/recursor": diff --git a/api/service/profile/service_test.go b/api/service/profile/service_test.go index fc667f3d..25e5a408 100644 --- a/api/service/profile/service_test.go +++ b/api/service/profile/service_test.go @@ -1004,6 +1004,55 @@ func (suite *ProfileTestSuite) TestUpdateProfile() { expectedError: "", expectProfile: true, }, + // Rebinding protection settings tests. specRef: G18 (api-endpoint-behaviour.md) + { + name: "Successfully update rebinding_protection enabled", + profileID: "profile123", + accountID: "account123", + updates: []model.ProfileUpdate{ + { + Operation: model.UpdateOperationReplace, + Path: "/settings/security/rebinding_protection/enabled", + Value: true, + }, + }, + existingProfile: &model.Profile{ + ProfileId: "profile123", + AccountId: "account123", + Name: "Test Profile", + Settings: &model.ProfileSettings{ + Security: &model.Security{ + RebindingProtection: model.RebindingProtection{Enabled: false}, + }, + }, + }, + expectedError: "", + expectProfile: true, + }, + { + name: "Update rebinding_protection enabled with invalid value", + profileID: "profile123", + accountID: "account123", + updates: []model.ProfileUpdate{ + { + Operation: model.UpdateOperationReplace, + Path: "/settings/security/rebinding_protection/enabled", + Value: "not_a_bool", + }, + }, + existingProfile: &model.Profile{ + ProfileId: "profile123", + AccountId: "account123", + Name: "Test Profile", + Settings: &model.ProfileSettings{ + Security: &model.Security{ + RebindingProtection: model.RebindingProtection{Enabled: false}, + }, + }, + }, + expectedError: "parsing", + expectProfile: false, + }, { name: "Update DNSSEC enabled with invalid value", profileID: "profile123", diff --git a/app/src/__tests__/unit/NrdGroup.test.ts b/app/src/__tests__/unit/NrdGroup.test.ts new file mode 100644 index 00000000..738b255c --- /dev/null +++ b/app/src/__tests__/unit/NrdGroup.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { + NRD_GROUP, + nrdDepthFromEnabled, + nrdDepthTargets, + orderedNrdItems, + isNrdItem, +} from "@/pages/blocklists/nrdGroup"; +import type { ModelBlocklist } from "@/api/client/api"; + +const ids = NRD_GROUP.map((t) => t.id); + +function bl(id: string, tags: string[] = []): ModelBlocklist { + return { blocklist_id: id, tags } as unknown as ModelBlocklist; +} + +describe("nrdGroup depth math", () => { + it("reports depth 0 when nothing is enabled", () => { + expect(nrdDepthFromEnabled(ids, [])).toBe(0); + }); + + it("reports the cumulative depth for the lowest contiguous tiers", () => { + expect(nrdDepthFromEnabled(ids, [ids[0]])).toBe(1); + expect(nrdDepthFromEnabled(ids, [ids[0], ids[1]])).toBe(2); + expect(nrdDepthFromEnabled(ids, ids)).toBe(5); + }); + + it("uses the highest enabled tier when the selection is gapped", () => { + expect(nrdDepthFromEnabled(ids, [ids[0], ids[2]])).toBe(3); + }); + + it("maps a target depth to cumulative enable/disable sets", () => { + expect(nrdDepthTargets(ids, 0)).toEqual({ enable: [], disable: ids }); + expect(nrdDepthTargets(ids, 2)).toEqual({ + enable: [ids[0], ids[1]], + disable: [ids[2], ids[3], ids[4]], + }); + expect(nrdDepthTargets(ids, 5)).toEqual({ enable: ids, disable: [] }); + }); + + it("clamps out-of-range depths", () => { + expect(nrdDepthTargets(ids, 99).enable).toEqual(ids); + expect(nrdDepthTargets(ids, -3).enable).toEqual([]); + }); +}); + +describe("nrdGroup item selection", () => { + it("identifies NRD items by known id or nrd tag", () => { + expect(isNrdItem(bl(ids[0]))).toBe(true); + expect(isNrdItem(bl("something_else", ["nrd"]))).toBe(true); + expect(isNrdItem(bl("hagezi_threat_intelligence_feeds_full"))).toBe(false); + }); + + it("orders present NRD items shortest→longest and skips missing tiers", () => { + const items = [bl(ids[2]), bl(ids[0]), bl("tif")]; + expect(orderedNrdItems(items).map((b) => b.blocklist_id)).toEqual([ + ids[0], + ids[2], + ]); + }); +}); diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts index d9a39f03..e6667e95 100644 --- a/app/src/api/client/api.ts +++ b/app/src/api/client/api.ts @@ -788,6 +788,7 @@ export const ModelProfileUpdatePathEnum = { SettingsPrivacyCustomRulesSubdomainsRule: '/settings/privacy/custom_rules_subdomains_rule', SettingsSecurityDnssecEnabled: '/settings/security/dnssec/enabled', SettingsSecurityDnssecSendDoBit: '/settings/security/dnssec/send_do_bit', + SettingsSecurityRebindingProtectionEnabled: '/settings/security/rebinding_protection/enabled', SettingsAdvancedRecursor: '/settings/advanced/recursor' } as const; @@ -854,6 +855,19 @@ export interface ModelQueryLog { */ 'timestamp'?: string; } +/** + * + * @export + * @interface ModelRebindingProtection + */ +export interface ModelRebindingProtection { + /** + * + * @type {boolean} + * @memberof ModelRebindingProtection + */ + 'enabled'?: boolean; +} /** * * @export @@ -883,6 +897,12 @@ export interface ModelSecurity { * @memberof ModelSecurity */ 'dnssec': ModelDNSSECSettings; + /** + * + * @type {ModelRebindingProtection} + * @memberof ModelSecurity + */ + 'rebinding_protection'?: ModelRebindingProtection; } /** * diff --git a/app/src/pages/blocklists/CategoriesContentSection.tsx b/app/src/pages/blocklists/CategoriesContentSection.tsx index b95c3df1..3f4c9c9c 100644 --- a/app/src/pages/blocklists/CategoriesContentSection.tsx +++ b/app/src/pages/blocklists/CategoriesContentSection.tsx @@ -200,15 +200,11 @@ export default function CategoriesContentSection({ const handleCategoryToggle = (categoryKey: string) => { const items = grouped.get(categoryKey) ?? []; const allIds = items.map((bl) => bl.blocklist_id); + if (allIds.length === 0) return; const enabledCount = items.filter((bl) => enabledBlocklists.includes(bl.blocklist_id) ).length; - - if (enabledCount > 0) { - onCategoryToggle(allIds, false); - } else { - onCategoryToggle(allIds, true); - } + onCategoryToggle(allIds, enabledCount === 0); }; // Find the expanded category's data for the panel diff --git a/app/src/pages/blocklists/MainContentSection.tsx b/app/src/pages/blocklists/MainContentSection.tsx index 03948eed..e406d1c6 100644 --- a/app/src/pages/blocklists/MainContentSection.tsx +++ b/app/src/pages/blocklists/MainContentSection.tsx @@ -21,6 +21,8 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { ApiV1BlocklistsGetSortByEnum, + ModelProfileUpdateOperationEnum, + ModelProfileUpdatePathEnum, type ApiBlocklistsUpdates, type ModelBlocklist, } from "@/api/client/api"; @@ -34,6 +36,7 @@ import { toast } from "sonner"; import axios from "axios"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import ServicesContentSection from "@/pages/blocklists/ServicesContentSection"; +import SecurityContentSection from "@/pages/blocklists/SecurityContentSection"; import CategoriesContentSection from "@/pages/blocklists/CategoriesContentSection"; const INDIVIDUAL_LISTS = [ @@ -41,6 +44,9 @@ const INDIVIDUAL_LISTS = [ { label: "Adguard", tag: "adguard" }, { label: "OISD", tag: "oisd" }, { label: "Steven Black", tag: "steven_black" }, + { label: "1Hosts", tag: "1hosts" }, + { label: "Peter Lowe", tag: "peter_lowe" }, + { label: "Dan Pollock", tag: "someonewhocares" }, ]; const PREDEFINED_LISTS = [ @@ -89,6 +95,7 @@ export default function MainContentSection(): JSX.Element { const [blocklists, setBlocklists] = useState([]); const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(null); + const [rebindingUpdating, setRebindingUpdating] = useState(false); const [searchValue, setSearchValue] = useState(""); const [filterValue, setFilterValue] = useState("all"); const [sortValue, setSortValue] = useState(ApiV1BlocklistsGetSortByEnum.Updated); @@ -168,6 +175,41 @@ export default function MainContentSection(): JSX.Element { } }; + // Handler to apply an exact target set in one action: enable some blocklists + // and disable others (used by the NRD range card to move between depths). + const handleApplyBlocklistSet = async (enableIds: string[], disableIds: string[]) => { + if (!activeProfile?.profile_id) return; + const toEnable = enableIds.filter((id) => !enabledBlocklists.includes(id)); + const toDisable = disableIds.filter((id) => enabledBlocklists.includes(id)); + if (toEnable.length === 0 && toDisable.length === 0) return; + setUpdating("nrd"); + try { + if (toEnable.length > 0) { + await api.Client.profilesApi.apiV1ProfilesIdBlocklistsPost( + activeProfile.profile_id, + { blocklist_ids: toEnable } as ApiBlocklistsUpdates + ); + } + if (toDisable.length > 0) { + await api.Client.profilesApi.apiV1ProfilesIdBlocklistsDelete( + activeProfile.profile_id, + { blocklist_ids: toDisable } as ApiBlocklistsUpdates + ); + } + const updatedProfile = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updatedProfile.data); + toast.success("Blocklists updated", { + description: "Your selection has been updated successfully.", + }); + } catch { + toast.error("Error", { + description: "Failed to update blocklists. Please try again.", + }); + } finally { + setUpdating(null); + } + }; + // Handler to enable/disable a blocklist for the user const handleBlocklistSwitch = async (blocklistId: string, checked: boolean) => { if (!activeProfile?.profile_id) return; @@ -206,8 +248,49 @@ export default function MainContentSection(): JSX.Element { } }; - // Split into regular and category blocklists using the `kind` field - const regularBlocklists = blocklists.filter((bl) => bl.kind !== "category"); + // DNS rebinding protection — per-profile Security toggle stored in + // settings.security.rebinding_protection.enabled (default off). + const rebindingEnabled = + activeProfile?.settings?.security?.rebinding_protection?.enabled ?? false; + + const handleRebindingToggle = async (enabled: boolean) => { + if (!activeProfile?.profile_id) return; + setRebindingUpdating(true); + try { + const resp = await api.Client.profilesApi.apiV1ProfilesIdPatch( + activeProfile.profile_id, + { + updates: [ + { + operation: ModelProfileUpdateOperationEnum.Replace, + path: ModelProfileUpdatePathEnum.SettingsSecurityRebindingProtectionEnabled, + value: enabled as unknown as object, + }, + ], + } + ); + if (resp && resp.status === 200) { + const updatedProfile = await api.Client.profilesApi.apiV1ProfilesIdGet(activeProfile.profile_id); + setActiveProfile(updatedProfile.data); + toast.success( + enabled ? "DNS rebinding protection enabled" : "DNS rebinding protection disabled" + ); + } + } catch { + toast.error("Error", { + description: "Failed to update DNS rebinding protection. Please try again.", + }); + } finally { + setRebindingUpdating(false); + } + }; + + // Split blocklists by `kind`: general lists (Lists tab), security lists + // (Security tab) and content categories (Categories tab). + const regularBlocklists = blocklists.filter( + (bl) => bl.kind !== "category" && bl.kind !== "security" + ); + const securityBlocklists = blocklists.filter((bl) => bl.kind === "security"); const categoryBlocklists = blocklists.filter((bl) => bl.kind === "category"); // Filter blocklists by search and filter value (basic, comprehensive, restrictive, all) @@ -316,6 +399,9 @@ export default function MainContentSection(): JSX.Element { Lists + + Security + Categories @@ -538,6 +624,23 @@ export default function MainContentSection(): JSX.Element { + + {activeTab === "security" ? ( + + ) : null} + + {activeTab === "services" ? : null} diff --git a/app/src/pages/blocklists/NrdRangeCard.tsx b/app/src/pages/blocklists/NrdRangeCard.tsx new file mode 100644 index 00000000..6f2db4a4 --- /dev/null +++ b/app/src/pages/blocklists/NrdRangeCard.tsx @@ -0,0 +1,171 @@ +import { type JSX, useMemo } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Tooltip } from "@/components/ui/tooltip"; +import { ExternalLinkIcon } from "lucide-react"; +import type { ModelBlocklist } from "@/api/client/api"; +import { formatUpdatedRelative } from "./MainContentSection"; +import { + NRD_GROUP, + nrdDepthFromEnabled, + nrdDepthTargets, +} from "./nrdGroup"; + +interface NrdRangeCardProps { + /** NRD blocklists present, ordered shortest→longest window. */ + items: ModelBlocklist[]; + enabledBlocklists: string[]; + /** Apply an exact target: enable some ids, disable others, in one action. */ + onApplySet: (enableIds: string[], disableIds: string[]) => void; + updating: string | null; + restricted?: boolean; +} + +function formatEntries(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return String(n); +} + +/** + * Single card representing the Hagezi NRD list family as a stepped + * registration-recency range (Off · 7d · … · 35d). Each step cumulatively + * enables the corresponding windows; the depth math lives in `nrdGroup.ts`. + */ +export default function NrdRangeCard({ + items, + enabledBlocklists, + onApplySet, + updating, + restricted = false, +}: NrdRangeCardProps): JSX.Element { + const orderedIds = useMemo(() => items.map((bl) => bl.blocklist_id), [items]); + const labelById = useMemo( + () => new Map(NRD_GROUP.map((t) => [t.id, t.label])), + [], + ); + + const depth = nrdDepthFromEnabled(orderedIds, enabledBlocklists); + const disabled = updating !== null || restricted; + + // Aggregate entries and freshness for the currently selected depth. + const selected = items.slice(0, depth); + const selectedEntries = selected.reduce( + (sum, bl) => sum + (typeof bl.entries === "number" ? bl.entries : 0), + 0, + ); + const mostRecent = selected.reduce((latest, bl) => { + if (!bl.last_modified) return latest; + if (!latest) return bl.last_modified; + return new Date(bl.last_modified) > new Date(latest) + ? bl.last_modified + : latest; + }, "" as string); + + const homepage = items[0]?.homepage; + + const handleChange = (value: string) => { + // Radix single toggle yields "" when the active item is re-pressed; ignore + // (the explicit "Off" step is the way to disable everything). + if (value === "") return; + const next = Number(value); + if (Number.isNaN(next) || next === depth) return; + const { enable, disable } = nrdDepthTargets(orderedIds, next); + onApplySet(enable, disable); + }; + + return ( + + +
+
+
+ Newly Registered Domains (NRD) +
+
+ Block recently registered domains, often abused for + phishing, malware and scams. Choose how far back to + block by registration date.{" "} + + Aggressive — expect false positives; whitelist + critical services as needed. + +
+
+ {homepage && ( + + + + )} +
+ +
+ + Registration window + + + + Off + + {items.map((bl, i) => ( + + {labelById.get(bl.blocklist_id) ?? `T${i + 1}`} + + ))} + +
+ +
+ + {depth === 0 + ? "Not blocking newly registered domains" + : `Blocking domains registered in the ${NRD_GROUP[depth - 1]?.window ?? "selected window"}`} + + + {depth === 0 + ? "0 entries" + : `${formatEntries(selectedEntries)} entries${mostRecent ? ` · updated ${formatUpdatedRelative(mostRecent)}` : ""}`} + +
+
+
+ ); +} diff --git a/app/src/pages/blocklists/SecurityContentSection.tsx b/app/src/pages/blocklists/SecurityContentSection.tsx new file mode 100644 index 00000000..daa21c0d --- /dev/null +++ b/app/src/pages/blocklists/SecurityContentSection.tsx @@ -0,0 +1,174 @@ +import { type ComponentType, type JSX, type ReactNode } from "react"; +import BlocklistCard from "./BlocklistCard"; +import NrdRangeCard from "./NrdRangeCard"; +import { isNrdItem, orderedNrdItems } from "./nrdGroup"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { ShieldCheck, ShieldAlert } from "lucide-react"; +import type { ModelBlocklist } from "@/api/client/api"; +import { formatUpdatedRelative } from "./MainContentSection"; + +interface SecurityContentSectionProps { + blocklists: ModelBlocklist[]; + enabledBlocklists: string[]; + onToggle: (id: string, checked: boolean) => void; + /** Apply an exact target set (enable some ids, disable others) in one action. */ + onApplySet: (enableIds: string[], disableIds: string[]) => void; + updating: string | null; + loading: boolean; + restricted?: boolean; + /** DNS rebinding protection per-profile toggle (settings.security.rebinding_protection.enabled). */ + rebindingEnabled: boolean; + onRebindingToggle: (enabled: boolean) => void; + rebindingUpdating: boolean; +} + +/** + * Group header for the Security tab. A left-aligned uppercase label with a tinted + * icon and a trailing gradient hairline — reuses the visual vocabulary of the + * connector bars in CategoriesContentSection so the tabs feel consistent. + */ +function SectionLabel({ + icon: Icon, + children, +}: { + icon: ComponentType<{ className?: string }>; + children: ReactNode; +}): JSX.Element { + return ( +
+
+ + {children} +
+
+
+ ); +} + +/** + * Security tab — split into two groups: "DNS Protection" (behavioural safeguards + * like DNS rebinding protection) and "Threat Blocklists" (subscribable domain + * lists: the Hagezi NRD range card plus individual security lists such as Threat + * Intelligence Feeds and CERT.pl). + */ +export default function SecurityContentSection({ + blocklists, + enabledBlocklists, + onToggle, + onApplySet, + updating, + loading, + restricted = false, + rebindingEnabled, + onRebindingToggle, + rebindingUpdating, +}: SecurityContentSectionProps): JSX.Element { + const nrdItems = orderedNrdItems(blocklists); + const regularItems = blocklists.filter((bl) => !isNrdItem(bl)); + const hasBlocklists = nrdItems.length > 0 || regularItems.length > 0; + + return ( +
+
+

+ Defend this profile against malware, phishing, scams, and + DNS-based attacks on your local network. Threat Intelligence + Feeds is enabled by default. +

+
+ +
+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+ ) : ( +
+ {/* Group 1 — behavioural protections (not subscribable lists) */} + DNS Protection + + +
+
+ DNS Rebinding Protection +
+
+ Block responses where a public domain resolves to a private or + local IP address (e.g. 192.168.x.x, 127.0.0.1), a technique used + in DNS rebinding attacks to reach devices on your network. +
+
+ +
+
+ + {/* Group 2 — subscribable threat blocklists */} + {hasBlocklists && ( + <> + Threat Blocklists +
+ {nrdItems.length > 0 && ( +
+ +
+ )} + {regularItems.map((bl) => { + const isEnabled = enabledBlocklists.includes(bl.blocklist_id); + return ( + onToggle(bl.blocklist_id, checked)} + switchChecked={isEnabled} + switchDisabled={updating === bl.blocklist_id || restricted} + homepage={bl.homepage} + /> + ); + })} +
+ + )} +
+ )} +
+
+
+ ); +} diff --git a/app/src/pages/blocklists/nrdGroup.ts b/app/src/pages/blocklists/nrdGroup.ts new file mode 100644 index 00000000..ec3149f7 --- /dev/null +++ b/app/src/pages/blocklists/nrdGroup.ts @@ -0,0 +1,82 @@ +import type { ModelBlocklist } from "@/api/client/api"; + +/** + * Presentation grouping for the Hagezi NRD (Newly Registered Domains) lists. + * + * The backend exposes the NRD windows as five independent, individually-toggleable + * blocklists. In the UI we collapse them into a single "range" card whose stepped + * control selects a registration-recency depth. The windows are non-overlapping + * slices designed to be combined cumulatively, so selecting depth `k` enables the + * first `k` tiers and disables the rest. This module holds the ordered id↔window + * mapping and the pure depth math so it can be unit-tested without rendering. + */ + +export const NRD_TAG = "nrd"; + +export interface NrdTier { + id: string; + /** Short label shown on the stepped control, e.g. "7d". */ + label: string; + /** Cumulative window described to the user, e.g. "last 7 days". */ + window: string; +} + +/** Ordered shortest→longest registration-recency window. */ +export const NRD_GROUP: NrdTier[] = [ + { id: "hagezi_nrd_07", label: "7d", window: "last 7 days" }, + { id: "hagezi_nrd_14_08", label: "14d", window: "last 14 days" }, + { id: "hagezi_nrd_21_15", label: "21d", window: "last 21 days" }, + { id: "hagezi_nrd_28_22", label: "28d", window: "last 28 days" }, + { id: "hagezi_nrd_35_29", label: "35d", window: "last 35 days" }, +]; + +const NRD_IDS = new Set(NRD_GROUP.map((t) => t.id)); + +/** True when a blocklist belongs to the NRD group (by tag or known id). */ +export function isNrdItem(bl: ModelBlocklist): boolean { + if (NRD_IDS.has(bl.blocklist_id)) return true; + return Array.isArray(bl.tags) && bl.tags.includes(NRD_TAG); +} + +/** + * The NRD blocklists present in `items`, ordered shortest→longest window. + * Tiers not present in the data are skipped (the control adapts to what exists). + */ +export function orderedNrdItems(items: ModelBlocklist[]): ModelBlocklist[] { + const byId = new Map(items.map((bl) => [bl.blocklist_id, bl])); + return NRD_GROUP.map((t) => byId.get(t.id)).filter( + (bl): bl is ModelBlocklist => bl !== undefined, + ); +} + +/** + * Current selected depth = the highest enabled tier (1-based), or 0 for "Off". + * Using the highest (rather than a contiguous count) means a gapped selection + * still surfaces sensibly and self-heals to a contiguous set on the next change. + */ +export function nrdDepthFromEnabled( + orderedIds: string[], + enabled: Iterable, +): number { + const enabledSet = enabled instanceof Set ? enabled : new Set(enabled); + let depth = 0; + orderedIds.forEach((id, i) => { + if (enabledSet.has(id)) depth = i + 1; + }); + return depth; +} + +/** + * Given a target depth, the cumulative enable/disable sets over the ordered ids. + * `enable` = first `depth` tiers; `disable` = the remainder. + */ +export function nrdDepthTargets( + orderedIds: string[], + depth: number, +): { enable: string[]; disable: string[] } { + const clamped = Math.max(0, Math.min(depth, orderedIds.length)); + return { + enable: orderedIds.slice(0, clamped), + disable: orderedIds.slice(clamped), + }; +} diff --git a/blocklists/README.md b/blocklists/README.md index e891c82a..48fec434 100644 --- a/blocklists/README.md +++ b/blocklists/README.md @@ -63,11 +63,12 @@ The **extractor is selected by the `blocklist_id` prefix** | `hagezi*` | Hagezi | plain domains, `#` headers (strict metadata) | | `oisd*` | OISD | plain domains (`domainswild2`), `#` headers | | `steven_black*` | Steven Black | hosts file (`0.0.0.0 domain`) | -| `blp_*`, `ut1_*`, `shadowwhisperer_*` | Domains | plain domains, graceful metadata, hosts-tolerant | +| `blp_*`, `ut1_*`, `shadowwhisperer_*`, `someonewhocares_*`, `peter_lowe*`, `1hosts_*` | Domains | plain or hosts-format domains, graceful metadata (optional `Last modified`/`Last updated`/`Count` headers), hosts-tolerant | Key source fields: `blocklist_id` (required, routes the extractor), `name`, `description`, `source_url`, `kind` (`general`/`category`/`security`), -`category`, `intensity`, `default`, `schedule` (cron). See any file under +`category` (e.g. `gambling`, only when `kind=category`), `intensity`, `default`, +`schedule` (cron). See any file under `sources/` for a complete example. The authoritative behaviour spec is diff --git a/blocklists/cache/redis.go b/blocklists/cache/redis.go index b7cc6d95..0b1b5e29 100644 --- a/blocklists/cache/redis.go +++ b/blocklists/cache/redis.go @@ -10,7 +10,17 @@ import ( "github.com/rs/zerolog/log" ) -const chunkSize = 5000 +const ( + // chunkSize is the number of members added per SADD command. + chunkSize = 5000 + // flushEvery is the number of buffered commands sent per pipeline + // round-trip. The full blocklist is never flushed as a single batch: large + // NRD lists hold millions of entries, and writing the whole pipeline under + // one socket write deadline overwhelms the master's drain rate and triggers + // "i/o timeout". Flushing in bounded batches keeps each round-trip small and + // gives each its own write deadline (~250k members per flush at chunkSize). + flushEvery = 50 +) // RedisCache is a cache implementation using Redis type RedisCache struct { @@ -36,13 +46,14 @@ func (c *RedisCache) CreateOrUpdateBlocklist(ctx context.Context, blocklistId st tempBlocklistName := fmt.Sprintf("%s_temp", blocklistName) oldBlocklistName := fmt.Sprintf("%s_old", blocklistName) + // Step 1: Populate the temp set with new data, flushing in bounded batches. pipe := c.client.Pipeline() // Step 0: Clear any stale temp set left by a previously crashed run, so the // new set is not silently merged with old data. pipe.Del(ctx, tempBlocklistName) + buffered := 1 // the Del command above - // Step 1: Create the temp set with new data lines := strings.Split(string(data), "\n") for i := 0; i < len(lines); i += chunkSize { end := i + chunkSize @@ -55,18 +66,37 @@ func (c *RedisCache) CreateOrUpdateBlocklist(ctx context.Context, blocklistId st continue } pipe.SAdd(ctx, tempBlocklistName, chunk) + + if buffered++; buffered >= flushEvery { + if _, err := pipe.Exec(ctx); err != nil { + log.Err(err).Str("component", "cache").Msg("Cache: pipeline execution failed") + return err + } + pipe = c.client.Pipeline() + buffered = 0 + } + } + // Flush any commands still buffered (the initial Del plus trailing SADDs). + if buffered > 0 { + if _, err := pipe.Exec(ctx); err != nil { + log.Err(err).Str("component", "cache").Msg("Cache: pipeline execution failed") + return err + } } - // Step 2: Atomically swap sets using RENAME + // Step 2: Atomically swap the populated temp set into place. A pipeline is + // not a transaction, so this is kept separate from population; the only + // atomic unit that matters is each individual RENAME. + swap := c.client.Pipeline() // If the original blocklist exists, rename it to _old - renameNXCmd := pipe.RenameNX(ctx, blocklistName, oldBlocklistName) + renameNXCmd := swap.RenameNX(ctx, blocklistName, oldBlocklistName) // Rename temp set to the main blocklist name - pipe.Rename(ctx, tempBlocklistName, blocklistName) + swap.Rename(ctx, tempBlocklistName, blocklistName) // Step 3: Delete the old set - pipe.Del(ctx, oldBlocklistName) + swap.Del(ctx, oldBlocklistName) - // Commit all commands in the pipeline - cmds, err := pipe.Exec(ctx) + // Commit the swap commands in the pipeline + cmds, err := swap.Exec(ctx) if err != nil { // Check if the only error is "ERR no such key" from RENAME or RENAME_NX ignore := false diff --git a/blocklists/cache/redis_test.go b/blocklists/cache/redis_test.go new file mode 100644 index 00000000..25060528 --- /dev/null +++ b/blocklists/cache/redis_test.go @@ -0,0 +1,109 @@ +package cache + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestCache(t *testing.T) (*RedisCache, *miniredis.Miniredis) { + t.Helper() + mr, err := miniredis.Run() + require.NoError(t, err) + t.Cleanup(mr.Close) + + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + return &RedisCache{client: rdb}, mr +} + +func makeDomains(n int) []byte { + var b strings.Builder + for i := 0; i < n; i++ { + if i > 0 { + b.WriteByte('\n') + } + fmt.Fprintf(&b, "d%d.example.com", i) + } + return []byte(b.String()) +} + +// TestCreateOrUpdateBlocklist_LargeInputMultipleFlushes verifies that an input +// large enough to span more than one pipeline flush batch is stored completely +// and leaves no temp/old residue. Guards the batched-flush refactor. +func TestCreateOrUpdateBlocklist_LargeInputMultipleFlushes(t *testing.T) { + rc, mr := newTestCache(t) + ctx := context.Background() + + // 260k entries => 52 SADD commands => crosses the flushEvery boundary, + // forcing at least two pipeline Exec round-trips. + const n = 260_000 + require.NoError(t, rc.CreateOrUpdateBlocklist(ctx, "big", makeDomains(n))) + + card, err := rc.client.SCard(ctx, "blocklist:big").Result() + require.NoError(t, err) + assert.Equal(t, int64(n), card) + + ok, err := rc.client.SIsMember(ctx, "blocklist:big", "d259999.example.com").Result() + require.NoError(t, err) + assert.True(t, ok, "last member should be present after multiple flushes") + + assert.False(t, mr.Exists("blocklist:big_temp"), "temp set must not survive") + assert.False(t, mr.Exists("blocklist:big_old"), "old set must not survive") +} + +// TestCreateOrUpdateBlocklist_FirstRun covers the path where the target set does +// not yet exist (RENAMENX hits "no such key", which must be ignored). +func TestCreateOrUpdateBlocklist_FirstRun(t *testing.T) { + rc, mr := newTestCache(t) + ctx := context.Background() + + require.NoError(t, rc.CreateOrUpdateBlocklist(ctx, "fresh", []byte("a.com\nb.com\nc.com"))) + + members, err := rc.client.SMembers(ctx, "blocklist:fresh").Result() + require.NoError(t, err) + assert.ElementsMatch(t, []string{"a.com", "b.com", "c.com"}, members) + assert.False(t, mr.Exists("blocklist:fresh_temp")) + assert.False(t, mr.Exists("blocklist:fresh_old")) +} + +// TestCreateOrUpdateBlocklist_ReplacesExisting verifies the atomic swap fully +// replaces a previously stored set (no stale members from the old version). +func TestCreateOrUpdateBlocklist_ReplacesExisting(t *testing.T) { + rc, mr := newTestCache(t) + ctx := context.Background() + + require.NoError(t, rc.CreateOrUpdateBlocklist(ctx, "swap", []byte("old1.com\nold2.com"))) + require.NoError(t, rc.CreateOrUpdateBlocklist(ctx, "swap", []byte("new1.com\nnew2.com\nnew3.com"))) + + members, err := rc.client.SMembers(ctx, "blocklist:swap").Result() + require.NoError(t, err) + assert.ElementsMatch(t, []string{"new1.com", "new2.com", "new3.com"}, members) + assert.False(t, mr.Exists("blocklist:swap_temp")) + assert.False(t, mr.Exists("blocklist:swap_old")) +} + +// TestCreateOrUpdateBlocklist_ClearsStaleTemp ensures a temp set left behind by a +// previously crashed run is discarded rather than merged into the new set. +func TestCreateOrUpdateBlocklist_ClearsStaleTemp(t *testing.T) { + rc, mr := newTestCache(t) + ctx := context.Background() + + // Simulate a crashed prior run that left a partial temp set. + require.NoError(t, rc.client.SAdd(ctx, "blocklist:stale_temp", "garbage.com").Err()) + + require.NoError(t, rc.CreateOrUpdateBlocklist(ctx, "stale", []byte("real.com"))) + + members, err := rc.client.SMembers(ctx, "blocklist:stale").Result() + require.NoError(t, err) + assert.ElementsMatch(t, []string{"real.com"}, members) + assert.NotContains(t, members, "garbage.com") + assert.False(t, mr.Exists("blocklist:stale_temp")) +} diff --git a/blocklists/go.mod b/blocklists/go.mod index bb00afcf..8f019e75 100644 --- a/blocklists/go.mod +++ b/blocklists/go.mod @@ -3,6 +3,7 @@ module github.com/ivpn/dns/blocklists go 1.25.8 require ( + github.com/alicebob/miniredis/v2 v2.38.0 github.com/getsentry/sentry-go v0.31.1 github.com/getsentry/sentry-go/zerolog v0.31.1 github.com/ivpn/dns/libs v0.0.0 @@ -36,6 +37,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/crypto v0.36.0 // indirect diff --git a/blocklists/go.sum b/blocklists/go.sum index ecb5eb48..3156e007 100644 --- a/blocklists/go.sum +++ b/blocklists/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -126,6 +128,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= diff --git a/blocklists/internal/extractor/domains.go b/blocklists/internal/extractor/domains.go index e23c8548..3a04e751 100644 --- a/blocklists/internal/extractor/domains.go +++ b/blocklists/internal/extractor/domains.go @@ -11,9 +11,13 @@ import ( ) var ( - // Common header patterns across plain domain list formats - domainsReLastModified = regexp.MustCompile(`(?i)^[#!]\s*Last modified:\s*(.+)`) + // Common header patterns across plain domain list formats. These tolerate the + // variations seen across providers: "Last modified" (Block List Project) and + // "Last updated" (Dan Pollock / Peter Lowe); "Entries"/"Number of Entries" + // (yoyo) and "Count: N rules" with a decorative prefix (1Hosts). + domainsReLastModified = regexp.MustCompile(`(?i)^[#!]\s*Last (?:modified|updated):\s*(.+)`) domainsReEntries = regexp.MustCompile(`(?i)^[#!]\s*(?:Number of )?Entries:\s*([\d,]+)`) + domainsReCount = regexp.MustCompile(`(?i)^[#!].*\bCount:\s*([\d,]+)`) ) // DomainsExtractor implements the Extractor interface for plain domain-per-line @@ -88,11 +92,17 @@ func (e *DomainsExtractor) ExtractMetadata(blocklistBytes []byte) (time.Time, st } } - if matches := domainsReEntries.FindStringSubmatch(line); matches != nil && !foundEntries { - cleaned := strings.ReplaceAll(matches[1], ",", "") - if n, err := strconv.Atoi(cleaned); err == nil { - numEntries = n - foundEntries = true + if !foundEntries { + matches := domainsReEntries.FindStringSubmatch(line) + if matches == nil { + matches = domainsReCount.FindStringSubmatch(line) + } + if matches != nil { + cleaned := strings.ReplaceAll(matches[1], ",", "") + if n, err := strconv.Atoi(cleaned); err == nil { + numEntries = n + foundEntries = true + } } } @@ -124,7 +134,9 @@ func (e *DomainsExtractor) ProcessLine(line string) (string, error) { return stripHostsIP(line), nil } -// parseFlexibleDate tries multiple date formats commonly found in blocklist headers +// parseFlexibleDate tries multiple date formats commonly found in blocklist +// headers. Parsed values are normalized to UTC so callers can compare instants +// without location-pointer differences (e.g. a "GMT" zone vs time.UTC). func parseFlexibleDate(s string) (time.Time, bool) { formats := []string{ "2006-01-02 15:04:05 MST", @@ -135,10 +147,13 @@ func parseFlexibleDate(s string) (time.Time, bool) { "02 Jan 2006", "Jan 02, 2006", time.RFC3339, + time.RFC3339Nano, // 1Hosts: 2026-06-09T10:59:53.132Z + time.RFC1123, // Peter Lowe/yoyo: Tue, 09 Jun 2026 14:53:58 GMT + "Mon, 02 Jan 2006 at 15:04:05 MST", // someonewhocares: Wed, 10 Jun 2026 at 00:35:57 GMT } for _, fmt := range formats { if t, err := time.Parse(fmt, s); err == nil { - return t, true + return t.UTC(), true } } return time.Time{}, false diff --git a/blocklists/internal/extractor/domains_test.go b/blocklists/internal/extractor/domains_test.go index 4f9cae32..3a4ff4fa 100644 --- a/blocklists/internal/extractor/domains_test.go +++ b/blocklists/internal/extractor/domains_test.go @@ -149,6 +149,44 @@ domain3.com`, wantNumEntries: 3, wantErr: false, }, + { + // specRef: #C11 — someonewhocares "Last updated: , at