feat: manage epay settings securely
Deploy staging to test server / deploy (push) Failing after 5m31s
Deploy staging to test server / deploy (push) Failing after 5m31s
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import "server-only";
|
||||
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
function strictEnvironmentChatEnabled() {
|
||||
const value = process.env.EPAY_CHAT_ENABLED?.trim().toLowerCase();
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function environmentConfigComplete() {
|
||||
return Boolean(
|
||||
process.env.EPAY_GATEWAY_URL?.trim()
|
||||
&& process.env.EPAY_PID?.trim()
|
||||
&& process.env.EPAY_KEY?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readEpayAvailability() {
|
||||
try {
|
||||
const { data, error } = await createAdminSupabaseClient()
|
||||
.from("epay_settings")
|
||||
.select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled")
|
||||
.eq("id", true)
|
||||
.maybeSingle();
|
||||
if (error?.code === "42P01") {
|
||||
return { enabled: strictEnvironmentChatEnabled() && environmentConfigComplete() };
|
||||
}
|
||||
if (error || !data) return { enabled: false };
|
||||
return {
|
||||
enabled: Boolean(
|
||||
data.chat_enabled
|
||||
&& data.gateway_url
|
||||
&& data.pid
|
||||
&& data.encrypted_key
|
||||
&& data.notify_url
|
||||
&& data.return_url
|
||||
&& data.site_name,
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { decryptEpayKey } from "./encryption-core";
|
||||
|
||||
export type EpaySettingsRow = {
|
||||
gateway_url: string;
|
||||
pid: string;
|
||||
encrypted_key: string;
|
||||
notify_url: string;
|
||||
return_url: string;
|
||||
site_name: string;
|
||||
chat_enabled: boolean;
|
||||
};
|
||||
|
||||
export class EpayConfigurationError extends Error {
|
||||
constructor(message = "易支付配置不可用") {
|
||||
super(message);
|
||||
this.name = "EpayConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
function validHttpUrl(value: string, label: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!/^https?:$/.test(url.protocol)) throw new Error();
|
||||
return url;
|
||||
} catch {
|
||||
throw new EpayConfigurationError(`${label} 无效`);
|
||||
}
|
||||
}
|
||||
|
||||
export function suggestedEpayUrls(siteAddress = process.env.SITE_ADDRESS) {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(siteAddress?.trim() || "http://localhost:3000");
|
||||
if (!/^https?:$/.test(base.protocol)) throw new Error();
|
||||
} catch {
|
||||
base = new URL("http://localhost:3000");
|
||||
}
|
||||
return {
|
||||
notifyUrl: new URL("/api/payment/epay/notify", base).toString(),
|
||||
returnUrl: new URL("/", base).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function environmentChatEnabled(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1";
|
||||
}
|
||||
|
||||
function completeConfig(values: {
|
||||
gateway: string;
|
||||
pid: string;
|
||||
key: string;
|
||||
notifyUrl: string;
|
||||
returnUrl: string;
|
||||
siteName: string;
|
||||
chatEnabled: boolean;
|
||||
}) {
|
||||
if (!values.gateway || !values.pid || !values.key || !values.notifyUrl || !values.returnUrl || !values.siteName) {
|
||||
throw new EpayConfigurationError();
|
||||
}
|
||||
const gatewayUrl = validHttpUrl(values.gateway.replace(/\/+$/, ""), "易支付网关地址");
|
||||
validHttpUrl(values.notifyUrl, "异步通知地址");
|
||||
validHttpUrl(values.returnUrl, "支付返回地址");
|
||||
return { gatewayUrl, pid: values.pid, key: values.key, notifyUrl: values.notifyUrl, returnUrl: values.returnUrl, siteName: values.siteName, chatEnabled: values.chatEnabled };
|
||||
}
|
||||
|
||||
export async function resolveEpayConfig(
|
||||
loadDatabaseRow: () => Promise<EpaySettingsRow | null>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
let row: EpaySettingsRow | null;
|
||||
try {
|
||||
row = await loadDatabaseRow();
|
||||
} catch {
|
||||
throw new EpayConfigurationError();
|
||||
}
|
||||
if (row) {
|
||||
return completeConfig({
|
||||
gateway: row.gateway_url.trim(),
|
||||
pid: row.pid.trim(),
|
||||
key: decryptEpayKey(row.encrypted_key, env.EPAY_CONFIG_ENCRYPTION_KEY),
|
||||
notifyUrl: row.notify_url.trim(),
|
||||
returnUrl: row.return_url.trim(),
|
||||
siteName: row.site_name.trim(),
|
||||
chatEnabled: row.chat_enabled,
|
||||
});
|
||||
}
|
||||
|
||||
const defaults = suggestedEpayUrls(env.SITE_ADDRESS);
|
||||
return completeConfig({
|
||||
gateway: env.EPAY_GATEWAY_URL?.trim() || "",
|
||||
pid: env.EPAY_PID?.trim() || "",
|
||||
key: env.EPAY_KEY?.trim() || "",
|
||||
notifyUrl: env.EPAY_NOTIFY_URL?.trim() || defaults.notifyUrl,
|
||||
returnUrl: env.EPAY_RETURN_URL?.trim() || defaults.returnUrl,
|
||||
siteName: env.EPAY_SITE_NAME?.trim() || "Jyotisha",
|
||||
chatEnabled: environmentChatEnabled(env.EPAY_CHAT_ENABLED),
|
||||
});
|
||||
}
|
||||
|
||||
export function epaySubmitUrl(gatewayUrl: URL) {
|
||||
const url = new URL(gatewayUrl.toString());
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`;
|
||||
url.search = "";
|
||||
return url;
|
||||
}
|
||||
@@ -1,44 +1,26 @@
|
||||
import "server-only";
|
||||
|
||||
const DEFAULT_NOTIFY_URL = "https://jyotisha.chat/api/payment/epay/notify";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import {
|
||||
epaySubmitUrl,
|
||||
EpayConfigurationError,
|
||||
resolveEpayConfig,
|
||||
suggestedEpayUrls,
|
||||
type EpaySettingsRow,
|
||||
} from "./config-core";
|
||||
|
||||
export class EpayConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "EpayConfigurationError";
|
||||
}
|
||||
export { epaySubmitUrl, EpayConfigurationError, resolveEpayConfig, suggestedEpayUrls };
|
||||
export type { EpaySettingsRow };
|
||||
|
||||
export async function readEpayConfig() {
|
||||
return resolveEpayConfig(async () => {
|
||||
const { data, error } = await createAdminSupabaseClient()
|
||||
.from("epay_settings")
|
||||
.select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled")
|
||||
.eq("id", true)
|
||||
.maybeSingle();
|
||||
if (error?.code === "42P01") return null;
|
||||
if (error) throw new Error();
|
||||
return data as EpaySettingsRow | null;
|
||||
});
|
||||
}
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new EpayConfigurationError(`${name} 未配置`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readEpayConfig() {
|
||||
const gateway = required("EPAY_GATEWAY_URL").replace(/\/+$/, "");
|
||||
let gatewayUrl: URL;
|
||||
try {
|
||||
gatewayUrl = new URL(gateway);
|
||||
} catch {
|
||||
throw new EpayConfigurationError("EPAY_GATEWAY_URL 无效");
|
||||
}
|
||||
if (!/^https?:$/.test(gatewayUrl.protocol)) throw new EpayConfigurationError("EPAY_GATEWAY_URL 必须使用 HTTP(S)");
|
||||
return {
|
||||
gatewayUrl,
|
||||
pid: required("EPAY_PID"),
|
||||
key: required("EPAY_KEY"),
|
||||
notifyUrl: process.env.EPAY_NOTIFY_URL?.trim() || DEFAULT_NOTIFY_URL,
|
||||
returnUrl: process.env.EPAY_RETURN_URL?.trim() || "https://jyotisha.chat/",
|
||||
siteName: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha",
|
||||
};
|
||||
}
|
||||
|
||||
export function epaySubmitUrl(gatewayUrl: URL) {
|
||||
const url = new URL(gatewayUrl.toString());
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`;
|
||||
url.search = "";
|
||||
return url;
|
||||
}
|
||||
|
||||
export { DEFAULT_NOTIFY_URL };
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const VERSION = "v1";
|
||||
|
||||
export class EpayEncryptionError extends Error {
|
||||
constructor() {
|
||||
super("易支付配置不可用");
|
||||
this.name = "EpayEncryptionError";
|
||||
}
|
||||
}
|
||||
|
||||
function encryptionKey(value = process.env.EPAY_CONFIG_ENCRYPTION_KEY) {
|
||||
if (!value?.trim()) throw new EpayEncryptionError();
|
||||
try {
|
||||
const key = Buffer.from(value.trim(), "base64");
|
||||
if (key.length !== 32 || key.toString("base64") !== value.trim()) throw new Error();
|
||||
return key;
|
||||
} catch {
|
||||
throw new EpayEncryptionError();
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptEpayKey(plaintext: string, masterKey?: string) {
|
||||
if (!plaintext) throw new EpayEncryptionError();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(masterKey), iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(".");
|
||||
}
|
||||
|
||||
export function decryptEpayKey(payload: string, masterKey?: string) {
|
||||
try {
|
||||
const [version, ivValue, tagValue, ciphertextValue, extra] = payload.split(".");
|
||||
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error();
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", encryptionKey(masterKey), Buffer.from(ivValue, "base64url"));
|
||||
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextValue, "base64url")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
if (!plaintext) throw new Error();
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new EpayEncryptionError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
export { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "./encryption-core";
|
||||
@@ -0,0 +1,64 @@
|
||||
import { promises as dns } from "node:dns";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const blockedHostnames = new Set([
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata.google.internal",
|
||||
]);
|
||||
|
||||
function blockedIpv4(address: string) {
|
||||
const parts = address.split(".").map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
const [a, b] = parts;
|
||||
return a === 0
|
||||
|| a === 10
|
||||
|| a === 127
|
||||
|| (a === 100 && b >= 64 && b <= 127)
|
||||
|| (a === 169 && b === 254)
|
||||
|| (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 0)
|
||||
|| (a === 192 && b === 168)
|
||||
|| (a === 198 && (b === 18 || b === 19))
|
||||
|| a >= 224;
|
||||
}
|
||||
|
||||
export function isPublicEpayAddress(address: string) {
|
||||
const version = isIP(address);
|
||||
if (version === 4) return !blockedIpv4(address);
|
||||
if (version !== 6) return false;
|
||||
const normalized = address.toLowerCase().split("%")[0];
|
||||
if (normalized.startsWith("::ffff:")) return isPublicEpayAddress(normalized.slice(7));
|
||||
return normalized !== "::"
|
||||
&& normalized !== "::1"
|
||||
&& !normalized.startsWith("fc")
|
||||
&& !normalized.startsWith("fd")
|
||||
&& !/^fe[89ab]/.test(normalized)
|
||||
&& !normalized.startsWith("2001:db8:");
|
||||
}
|
||||
|
||||
export function assertPublicEpayGateway(value: URL | string) {
|
||||
const url = value instanceof URL ? value : new URL(value);
|
||||
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
|
||||
if (!/^https?:$/.test(url.protocol)
|
||||
|| url.username
|
||||
|| url.password
|
||||
|| blockedHostnames.has(hostname)
|
||||
|| hostname.endsWith(".localhost")
|
||||
|| hostname.endsWith(".local")
|
||||
|| (isIP(hostname) && !isPublicEpayAddress(hostname))) {
|
||||
throw new Error("易支付网关地址不允许指向本机或内网");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function assertPublicGatewayUrl(value: URL | string) {
|
||||
const url = assertPublicEpayGateway(value);
|
||||
if (!isIP(url.hostname)) {
|
||||
const addresses = await dns.lookup(url.hostname, { all: true, verbatim: true });
|
||||
if (!addresses.length || addresses.some(({ address }) => !isPublicEpayAddress(address))) {
|
||||
throw new Error("易支付网关地址不允许解析到本机或内网");
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
Reference in New Issue
Block a user