fix(dns): enforce the configured OVH zone on record operations

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Ron
2026-10-08 09:13:50 +03:00
parent e8f236c9db
commit 2674eb200d
3 changed files with 58 additions and 3 deletions
+40
View File
@@ -600,3 +600,43 @@ describe("ovhClient.testConnection", () => {
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
});
});
describe("ovhClient with a configured zone", () => {
const record = {
type: "A" as const,
name: "app.other.org",
content: "1.2.3.4",
};
it("refuses record operations on any other zone without calling OVH", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const message = /restricted to the zone "example.com"/;
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
message,
);
await expect(
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
).rejects.toThrow(message);
await expect(
ovhClient.updateRecord(cfg, "other.org", "1", record),
).rejects.toThrow(message);
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
message,
);
expect(apiCalls()).toHaveLength(0);
});
it("still allows operations on the configured zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess(null), ovhSuccess(null));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "1"),
).resolves.toBeUndefined();
expect(apiCalls()[0]?.[0]).toBe(
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
);
});
});
@@ -559,9 +559,12 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>
Restrict this provider to a single DNS zone. Leave empty
to use every zone the credentials can access. When set,
the <code>GET /domain/zone</code> right is not needed.
Restrict this provider to a single DNS zone: Dokploy
will only list and change records in it. Leave empty to
use every zone the credentials can access. When set, the{" "}
<code>GET /domain/zone</code> right is not needed. This
is enforced by Dokploy only, so also limit the OVH token
to that zone if you need a hard guarantee.
</FormDescription>
<FormMessage />
</FormItem>
+12
View File
@@ -252,6 +252,14 @@ const listZoneNames = async (config: OvhConfig) => {
}
};
const assertZoneAllowed = (config: OvhConfig, zoneId: string) => {
if (config.zone && zoneId !== config.zone) {
throw new Error(
`OVH: this provider is restricted to the zone "${config.zone}", not "${zoneId}"`,
);
}
};
export const ovhClient: DnsClient<OvhConfig> = {
async listZones(config) {
if (config.zone) {
@@ -262,6 +270,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async listRecords(config, zoneId) {
assertZoneAllowed(config, zoneId);
const zone = encodeURIComponent(zoneId);
// The listing endpoint only returns ids, so each record is fetched on its own.
const ids = await ovhFetch<number[]>(config, `/domain/zone/${zone}/record`);
@@ -279,6 +288,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async upsertRecord(config, record) {
assertZoneAllowed(config, record.zoneId);
const zone = encodeURIComponent(record.zoneId);
const subDomain = toSubDomain(record.name, record.zoneId);
const existing = await ovhFetch<number[]>(
@@ -322,6 +332,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async updateRecord(config, zoneId, recordId, record) {
assertZoneAllowed(config, zoneId);
const zone = encodeURIComponent(zoneId);
const existing = await ovhFetch<OvhRecord>(
config,
@@ -366,6 +377,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async deleteRecord(config, zoneId, recordId) {
assertZoneAllowed(config, zoneId);
await ovhFetch(
config,
`/domain/zone/${encodeURIComponent(zoneId)}/record/${recordId}`,