Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@.github/copilot-instructions.md
2 changes: 1 addition & 1 deletion api-docs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: DBackup API
version: 2.5.1
version: 2.6.0
description: |
REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention.

Expand Down
2 changes: 2 additions & 0 deletions docs/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@../.github/instructions/docs.instructions.md
@../.github/instructions/changelog.instructions.md
41 changes: 41 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,47 @@

All notable changes to DBackup are documented here.

## v2.6.0 - Security Update, Vault Credential Profiles, OAuth Improvements, and Multiple Bug Fixes
*Released: June 6, 2026*

> 🔒 **Security Update:** This release fixes a security vulnerability in DBackup's own code ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)). Update as soon as possible.

> ⚠️ **Breaking:** OAuth storage destinations (Dropbox, Google Drive, OneDrive) and token-based notification channels (Discord, Slack, Teams, Generic Webhook, Twilio) no longer store secrets inline - they require a Vault credential profile to function. After updating, create a matching `OAUTH`, `WEBHOOK`, or `TOKEN` profile in the Security Vault and assign it to each affected adapter via the edit form. Adapters without an assigned profile will fail connection tests and backup/notification jobs until migrated.

### ✨ Features

- **credentials**: Credential profiles now support `WEBHOOK` (Discord, Slack, Teams, Generic Webhook), `OAUTH` (Dropbox, Google Drive, OneDrive), and `TOKEN` (Twilio) types, allowing notification and storage secrets to be stored in the vault and resolved server-side.
- **OAuth**: OAuth authorization flows (Dropbox, Google Drive, OneDrive) now require an assigned credential profile. Tokens are stored in the vault and the credential picker refreshes automatically after authorization.
- **OAuth**: Authorization dialogs now open in a popup window instead of redirecting the current page.

### 🔒 Security

- **SMB**: Passwords are now redacted from error messages and logs when SMB connections fail.
- **adapters**: All adapter endpoints (list, create, update, clone) now return a safe DTO that strips all sensitive fields and replaces them with a `secretStatus` map - decrypted secrets are no longer serialized to API responses. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc))
- **adapters**: Notification webhook URLs and tokens (`webhookUrl`, `botToken`, `authToken`, `authHeader`, `appToken`, `accessToken`) and SSH keys (`sshPassword`, `sshPrivateKey`, `sshPassphrase`) added to `SENSITIVE_KEYS` and redacted in all DTO and strip operations. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc))
- **OAuth**: Refresh tokens for Dropbox, Google Drive, and OneDrive are now stored exclusively in credential profiles instead of adapter configs.
- **adapters**: Adapter update (PUT) now preserves existing secrets via `mergeSecrets` - re-saving an adapter form without changing secret fields no longer overwrites stored credentials with empty values.

### 🎨 Improvements

- **adapters**: Secret fields in the adapter form show a "saved - leave blank to keep" placeholder when a value is already stored.
- **credentials**: Credential profile dialog extended to support creating `WEBHOOK` and `OAUTH` profile types.
- **encryption**: The key resolution dialog now automatically switches to the raw key tab when no vault profiles are available.

### 🧪 Tests

- **adapters**: Added audit tests verifying no sensitive fields are returned by any adapter API endpoint.
- **adapters**: Added DTO unit tests verifying that notification secrets (Telegram `botToken`, Discord `webhookUrl`) are redacted and `secretStatus` flags are set correctly.
- **crypto**: Added unit tests for `stripSecrets`, `mergeSecrets`, and `getSecretStatus`.

### 🐳 Docker

- **Image**: `skyfay/dbackup:v2.6.0`
- **Also tagged as**: `latest`, `v2`
- **CI Image**: `skyfay/dbackup:ci`
- **Platforms**: linux/amd64, linux/arm64


## v2.5.1 - Security Update, Smart Recovery Improvements, and Multiple Bug Fixes
*Released: June 2, 2026*

Expand Down
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dbackup-docs",
"version": "2.5.1",
"version": "2.6.0",
"private": true,
"scripts": {
"dev": "vitepress dev",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dbackup",
"version": "2.5.1",
"version": "2.6.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion public/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: DBackup API
version: 2.5.1
version: 2.6.0
description: |
REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention.

Expand Down
2 changes: 2 additions & 0 deletions src/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@../.github/instructions/logic.instructions.md
@../.github/instructions/ui.instructions.md
3 changes: 2 additions & 1 deletion src/app/api/adapters/[id]/clone/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PERMISSIONS, Permission } from "@/lib/auth/permissions";
import { logger } from "@/lib/logging/logger";
import { wrapError, getErrorMessage } from "@/lib/logging/errors";
import { registerAdapters } from "@/lib/adapters";
import { toAdapterListItem } from "@/lib/adapters/dto";

registerAdapters();

Expand Down Expand Up @@ -82,7 +83,7 @@ export async function POST(
cloned.id
);

return NextResponse.json(cloned, { status: 201 });
return NextResponse.json(toAdapterListItem(cloned), { status: 201 });
} catch (error: unknown) {
log.error("Clone adapter error", { adapterId: params.id }, wrapError(error));
return NextResponse.json({
Expand Down
29 changes: 22 additions & 7 deletions src/app/api/adapters/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { encryptConfig } from "@/lib/crypto";
import { encryptConfig, decryptConfig, mergeSecrets } from "@/lib/crypto";
import { toAdapterListItem } from "@/lib/adapters/dto";
import { headers } from "next/headers";
import { auditService } from "@/services/audit-service";
import { AUDIT_ACTIONS, AUDIT_RESOURCES } from "@/lib/core/audit-types";
Expand Down Expand Up @@ -111,7 +112,7 @@ export async function PUT(
// RBAC: Check permission based on adapter type
const existingAdapter = await prisma.adapterConfig.findUnique({
where: { id: params.id },
select: { type: true, adapterId: true, lastError: true }
select: { type: true, adapterId: true, lastError: true, config: true }
});
if (!existingAdapter) {
return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 });
Expand Down Expand Up @@ -151,15 +152,29 @@ export async function PUT(
}
}

const configObj = typeof config === 'string' ? JSON.parse(config) : config;
const encryptedConfig = encryptConfig(configObj);
const configString = JSON.stringify(encryptedConfig);
// Build the encrypted config string only when a config payload was supplied.
// Secret-preserving merge: the API returns redacted secrets, so an edit
// round-trip submits empty secret fields. Re-fill them from the existing
// (decrypted) config before re-encrypting so we never clobber a real
// secret with an encrypted empty string (data-loss bug).
let configString: string | undefined;
if (config !== undefined) {
const incomingConfig = typeof config === 'string' ? JSON.parse(config) : config;
let existingDecrypted: unknown = {};
try {
existingDecrypted = decryptConfig(JSON.parse(existingAdapter.config));
} catch (e) {
log.warn("Failed to decrypt existing config during update; secret merge skipped", { adapterId: params.id }, wrapError(e));
}
const mergedConfig = mergeSecrets(incomingConfig, existingDecrypted);
configString = JSON.stringify(encryptConfig(mergedConfig));
}

const updatedAdapter = await prisma.adapterConfig.update({
where: { id: params.id },
data: {
name,
config: configString,
...(configString !== undefined ? { config: configString } : {}),
...(primaryCredentialId !== undefined ? { primaryCredentialId: primaryCredentialId ?? null } : {}),
...(sshCredentialId !== undefined ? { sshCredentialId: sshCredentialId ?? null } : {}),
...(metadata !== undefined ? { metadata: JSON.stringify(metadata) } : {}),
Expand All @@ -180,7 +195,7 @@ export async function PUT(
);
}

return NextResponse.json(updatedAdapter);
return NextResponse.json(toAdapterListItem(updatedAdapter));
} catch (_error) {
return NextResponse.json({ error: "Failed to update adapter" }, { status: 500 });
}
Expand Down
36 changes: 14 additions & 22 deletions src/app/api/adapters/dropbox/auth/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { DropboxAuth } from "dropbox";
import { headers } from "next/headers";
import prisma from "@/lib/prisma";
import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
import { decryptConfig } from "@/lib/crypto";
import { getDecryptedCredentialData } from "@/services/auth/credential-service";
import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";

const log = logger.child({ route: "adapters/dropbox/auth" });

/**
* POST /api/adapters/dropbox/auth
* Generates the Dropbox OAuth authorization URL.
* Body: { adapterId: string } - The saved adapter config ID to authorize.
* Body: { credentialId: string } - The OAUTH credential profile to authorize.
* The resulting refresh token is written back into that profile.
*/
export async function POST(req: NextRequest) {
const ctx = await getAuthContext(await headers());
Expand All @@ -21,25 +22,16 @@ export async function POST(req: NextRequest) {
}

try {
checkPermissionWithContext(ctx, PERMISSIONS.DESTINATIONS.WRITE);
checkPermissionWithContext(ctx, PERMISSIONS.CREDENTIALS.WRITE);

const { adapterId } = await req.json();
if (!adapterId) {
return NextResponse.json({ error: "Missing adapterId" }, { status: 400 });
const { credentialId } = await req.json();
if (!credentialId) {
return NextResponse.json({ error: "Missing credentialId" }, { status: 400 });
}

// Load the adapter config to get clientId and clientSecret
const adapterConfig = await prisma.adapterConfig.findUnique({
where: { id: adapterId },
});

if (!adapterConfig || adapterConfig.adapterId !== "dropbox") {
return NextResponse.json({ error: "Adapter not found or not a Dropbox adapter" }, { status: 404 });
}

const config = decryptConfig(JSON.parse(adapterConfig.config));
const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData;

if (!config.clientId || !config.clientSecret) {
if (!profile.clientId || !profile.clientSecret) {
return NextResponse.json({ error: "App Key and App Secret are required" }, { status: 400 });
}

Expand All @@ -48,22 +40,22 @@ export async function POST(req: NextRequest) {
const redirectUri = `${origin}/api/adapters/dropbox/callback`;

const dbxAuth = new DropboxAuth({
clientId: config.clientId,
clientSecret: config.clientSecret,
clientId: profile.clientId,
clientSecret: profile.clientSecret,
fetch: fetch,
});

const authUrl = await dbxAuth.getAuthenticationUrl(
redirectUri,
adapterId, // state parameter for callback
credentialId, // state parameter for callback
"code",
"offline", // Request offline access to get refresh_token
undefined, // scopes - use app-configured scopes
"none",
false
);

log.info("Generated Dropbox OAuth URL", { adapterId });
log.info("Generated Dropbox OAuth URL", { credentialId });

return NextResponse.json({ success: true, data: { authUrl: String(authUrl) } });
} catch (error) {
Expand Down
48 changes: 14 additions & 34 deletions src/app/api/adapters/dropbox/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { decryptConfig, encryptConfig } from "@/lib/crypto";
import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service";
import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
Expand All @@ -10,8 +10,8 @@ const log = logger.child({ route: "adapters/dropbox/callback" });
/**
* GET /api/adapters/dropbox/callback
* Handles the OAuth callback from Dropbox.
* Exchanges auth code for tokens and stores the refresh token in the adapter config.
* Redirects back to the destinations page with success/error status.
* `state` is the OAUTH credential profile id; the refresh token is written back
* into that profile. Redirects back to the destinations page with status.
*/
export async function GET(req: NextRequest) {
const origin = process.env.BETTER_AUTH_URL || req.nextUrl.origin;
Expand All @@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
}

const code = req.nextUrl.searchParams.get("code");
const state = req.nextUrl.searchParams.get("state"); // adapter config ID
const state = req.nextUrl.searchParams.get("state"); // OAUTH credential profile id
const error = req.nextUrl.searchParams.get("error");
const errorDescription = req.nextUrl.searchParams.get("error_description");

Expand All @@ -46,18 +46,8 @@ export async function GET(req: NextRequest) {
}

try {
// Load the adapter config
const adapterConfig = await prisma.adapterConfig.findUnique({
where: { id: state },
});

if (!adapterConfig || adapterConfig.adapterId !== "dropbox") {
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Adapter not found.")}`
);
}

const config = decryptConfig(JSON.parse(adapterConfig.config));
// `state` is the OAUTH credential profile id.
const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData;

const redirectUri = `${origin}/api/adapters/dropbox/callback`;

Expand All @@ -71,8 +61,8 @@ export async function GET(req: NextRequest) {
body: new URLSearchParams({
code,
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
client_id: profile.clientId,
client_secret: profile.clientSecret,
redirect_uri: redirectUri,
}),
});
Expand All @@ -88,28 +78,18 @@ export async function GET(req: NextRequest) {
const tokenData = await tokenRes.json();

if (!tokenData.refresh_token) {
log.warn("No refresh token received from Dropbox", { adapterId: state });
log.warn("No refresh token received from Dropbox", { credentialId: state });
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("No refresh token received. Ensure the app has 'offline' access configured.")}`
);
}

// Update the adapter config with the refresh token
const updatedConfig = {
...config,
refreshToken: tokenData.refresh_token,
};

const encryptedConfig = encryptConfig(updatedConfig);

await prisma.adapterConfig.update({
where: { id: state },
data: {
config: JSON.stringify(encryptedConfig),
},
// Store the refresh token in the credential profile.
await updateCredentialProfile(state, {
data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokenData.refresh_token } satisfies OAuthData,
});

log.info("Dropbox OAuth completed successfully", { adapterId: state });
log.info("Dropbox OAuth completed successfully", { credentialId: state });

return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=success&message=${encodeURIComponent("Dropbox authorized successfully!")}`
Expand Down
Loading
Loading