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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/cache/redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
},
Expand Down Expand Up @@ -3023,6 +3024,14 @@ const docTemplate = `{
}
}
},
"model.RebindingProtection": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"model.Retention": {
"type": "string",
"enum": [
Expand All @@ -3048,6 +3057,9 @@ const docTemplate = `{
"properties": {
"dnssec": {
"$ref": "#/definitions/model.DNSSECSettings"
},
"rebinding_protection": {
"$ref": "#/definitions/model.RebindingProtection"
}
}
},
Expand Down
12 changes: 12 additions & 0 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
},
Expand Down Expand Up @@ -3015,6 +3016,14 @@
}
}
},
"model.RebindingProtection": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"model.Retention": {
"type": "string",
"enum": [
Expand All @@ -3040,6 +3049,9 @@
"properties": {
"dnssec": {
"$ref": "#/definitions/model.DNSSECSettings"
},
"rebinding_protection": {
"$ref": "#/definitions/model.RebindingProtection"
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down Expand Up @@ -420,6 +421,11 @@ definitions:
timestamp:
type: string
type: object
model.RebindingProtection:
properties:
enabled:
type: boolean
type: object
model.Retention:
enum:
- 1h
Expand All @@ -438,6 +444,8 @@ definitions:
properties:
dnssec:
$ref: '#/definitions/model.DNSSECSettings'
rebinding_protection:
$ref: '#/definitions/model.RebindingProtection'
required:
- dnssec
type: object
Expand Down
2 changes: 1 addition & 1 deletion api/model/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
3 changes: 3 additions & 0 deletions api/model/profile_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ func NewSettings() *ProfileSettings {
Enabled: true,
SendDoBit: false,
},
RebindingProtection: RebindingProtection{
Enabled: false,
},
},
Logs: &LogsSettings{
Enabled: false,
Expand Down
10 changes: 9 additions & 1 deletion api/model/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
29 changes: 29 additions & 0 deletions api/service/profile/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
49 changes: 49 additions & 0 deletions api/service/profile/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions app/src/__tests__/unit/NrdGroup.test.ts
Original file line number Diff line number Diff line change
@@ -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],
]);
});
});
20 changes: 20 additions & 0 deletions app/src/api/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -854,6 +855,19 @@ export interface ModelQueryLog {
*/
'timestamp'?: string;
}
/**
*
* @export
* @interface ModelRebindingProtection
*/
export interface ModelRebindingProtection {
/**
*
* @type {boolean}
* @memberof ModelRebindingProtection
*/
'enabled'?: boolean;
}
/**
*
* @export
Expand Down Expand Up @@ -883,6 +897,12 @@ export interface ModelSecurity {
* @memberof ModelSecurity
*/
'dnssec': ModelDNSSECSettings;
/**
*
* @type {ModelRebindingProtection}
* @memberof ModelSecurity
*/
'rebinding_protection'?: ModelRebindingProtection;
}
/**
*
Expand Down
8 changes: 2 additions & 6 deletions app/src/pages/blocklists/CategoriesContentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading