580 lines
18 KiB
TypeScript
580 lines
18 KiB
TypeScript
/**
|
|
* Nostr Profile HTTP Handler
|
|
*
|
|
* Handles HTTP requests for profile management:
|
|
* - PUT /api/channels/nostr/:accountId/profile - Update and publish profile
|
|
* - POST /api/channels/nostr/:accountId/profile/import - Import from relays
|
|
* - GET /api/channels/nostr/:accountId/profile - Get current profile state
|
|
*/
|
|
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import {
|
|
createFixedWindowRateLimiter,
|
|
isBlockedHostnameOrIp,
|
|
readJsonBodyWithLimit,
|
|
requestBodyErrorToText,
|
|
} from "openclaw/plugin-sdk/nostr";
|
|
import { z } from "zod";
|
|
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
|
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
|
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
export interface NostrProfileHttpContext {
|
|
/** Get current profile from config */
|
|
getConfigProfile: (accountId: string) => NostrProfile | undefined;
|
|
/** Update profile in config (after successful publish) */
|
|
updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise<void>;
|
|
/** Get account's public key and relays */
|
|
getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null;
|
|
/** Logger */
|
|
log?: {
|
|
info: (msg: string) => void;
|
|
warn: (msg: string) => void;
|
|
error: (msg: string) => void;
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Rate Limiting
|
|
// ============================================================================
|
|
|
|
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
|
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
|
|
const RATE_LIMIT_MAX_TRACKED_KEYS = 2_048;
|
|
const profileRateLimiter = createFixedWindowRateLimiter({
|
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
maxRequests: RATE_LIMIT_MAX_REQUESTS,
|
|
maxTrackedKeys: RATE_LIMIT_MAX_TRACKED_KEYS,
|
|
});
|
|
|
|
export function clearNostrProfileRateLimitStateForTest(): void {
|
|
profileRateLimiter.clear();
|
|
}
|
|
|
|
export function getNostrProfileRateLimitStateSizeForTest(): number {
|
|
return profileRateLimiter.size();
|
|
}
|
|
|
|
export function isNostrProfileRateLimitedForTest(accountId: string, nowMs: number): boolean {
|
|
return profileRateLimiter.isRateLimited(accountId, nowMs);
|
|
}
|
|
|
|
function checkRateLimit(accountId: string): boolean {
|
|
return !profileRateLimiter.isRateLimited(accountId);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mutex for Concurrent Publish Prevention
|
|
// ============================================================================
|
|
|
|
const publishLocks = new Map<string, Promise<void>>();
|
|
|
|
async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
|
|
// Atomic mutex using promise chaining - prevents TOCTOU race condition
|
|
const prev = publishLocks.get(accountId) ?? Promise.resolve();
|
|
let resolve: () => void;
|
|
const next = new Promise<void>((r) => {
|
|
resolve = r;
|
|
});
|
|
// Atomically replace the lock before awaiting - any concurrent request
|
|
// will now wait on our `next` promise
|
|
publishLocks.set(accountId, next);
|
|
|
|
// Wait for previous operation to complete
|
|
await prev.catch(() => {});
|
|
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
resolve!();
|
|
// Clean up if we're the last in chain
|
|
if (publishLocks.get(accountId) === next) {
|
|
publishLocks.delete(accountId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SSRF Protection
|
|
// ============================================================================
|
|
|
|
function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
|
|
try {
|
|
const url = new URL(urlStr);
|
|
|
|
if (url.protocol !== "https:") {
|
|
return { ok: false, error: "URL must use https:// protocol" };
|
|
}
|
|
|
|
const hostname = url.hostname.toLowerCase();
|
|
|
|
if (isBlockedHostnameOrIp(hostname)) {
|
|
return { ok: false, error: "URL must not point to private/internal addresses" };
|
|
}
|
|
|
|
return { ok: true };
|
|
} catch {
|
|
return { ok: false, error: "Invalid URL format" };
|
|
}
|
|
}
|
|
|
|
// Export for use in import validation
|
|
export { validateUrlSafety };
|
|
|
|
// ============================================================================
|
|
// Validation Schemas
|
|
// ============================================================================
|
|
|
|
// NIP-05 format: user@domain.com
|
|
const nip05FormatSchema = z
|
|
.string()
|
|
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)")
|
|
.optional();
|
|
|
|
// LUD-16 Lightning address format: user@domain.com
|
|
const lud16FormatSchema = z
|
|
.string()
|
|
.regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format")
|
|
.optional();
|
|
|
|
// Extended profile schema with additional format validation
|
|
const ProfileUpdateSchema = NostrProfileSchema.extend({
|
|
nip05: nip05FormatSchema,
|
|
lud16: lud16FormatSchema,
|
|
});
|
|
|
|
// ============================================================================
|
|
// Request Helpers
|
|
// ============================================================================
|
|
|
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
res.statusCode = status;
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
async function readJsonBody(
|
|
req: IncomingMessage,
|
|
maxBytes = 64 * 1024,
|
|
timeoutMs = 30_000,
|
|
): Promise<unknown> {
|
|
const result = await readJsonBodyWithLimit(req, {
|
|
maxBytes,
|
|
timeoutMs,
|
|
emptyObjectOnEmpty: true,
|
|
});
|
|
if (result.ok) {
|
|
return result.value;
|
|
}
|
|
if (result.code === "PAYLOAD_TOO_LARGE") {
|
|
throw new Error("Request body too large");
|
|
}
|
|
if (result.code === "REQUEST_BODY_TIMEOUT") {
|
|
throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
}
|
|
if (result.code === "CONNECTION_CLOSED") {
|
|
throw new Error(requestBodyErrorToText("CONNECTION_CLOSED"));
|
|
}
|
|
throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error);
|
|
}
|
|
|
|
function parseAccountIdFromPath(pathname: string): string | null {
|
|
// Match: /api/channels/nostr/:accountId/profile
|
|
const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
|
|
if (!remoteAddress) {
|
|
return false;
|
|
}
|
|
|
|
const ipLower = remoteAddress.toLowerCase().replace(/^\[|\]$/g, "");
|
|
|
|
// IPv6 loopback
|
|
if (ipLower === "::1") {
|
|
return true;
|
|
}
|
|
|
|
// IPv4 loopback (127.0.0.0/8)
|
|
if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) {
|
|
return true;
|
|
}
|
|
|
|
// IPv4-mapped IPv6
|
|
const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
if (v4Mapped) {
|
|
return isLoopbackRemoteAddress(v4Mapped[1]);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isLoopbackOriginLike(value: string): boolean {
|
|
try {
|
|
const url = new URL(value);
|
|
const hostname = url.hostname.toLowerCase();
|
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
|
if (Array.isArray(value)) {
|
|
return value[0];
|
|
}
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function normalizeIpCandidate(raw: string): string {
|
|
const unquoted = raw.trim().replace(/^"|"$/g, "");
|
|
const bracketedWithOptionalPort = unquoted.match(/^\[([^[\]]+)\](?::\d+)?$/);
|
|
if (bracketedWithOptionalPort) {
|
|
return bracketedWithOptionalPort[1] ?? "";
|
|
}
|
|
const ipv4WithPort = unquoted.match(/^(\d+\.\d+\.\d+\.\d+):\d+$/);
|
|
if (ipv4WithPort) {
|
|
return ipv4WithPort[1] ?? "";
|
|
}
|
|
return unquoted;
|
|
}
|
|
|
|
function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean {
|
|
const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]);
|
|
if (forwardedFor) {
|
|
for (const hop of forwardedFor.split(",")) {
|
|
const candidate = normalizeIpCandidate(hop);
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
if (!isLoopbackRemoteAddress(candidate)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const realIp = firstHeaderValue(req.headers["x-real-ip"]);
|
|
if (realIp) {
|
|
const candidate = normalizeIpCandidate(realIp);
|
|
if (candidate && !isLoopbackRemoteAddress(candidate)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function enforceLoopbackMutationGuards(
|
|
ctx: NostrProfileHttpContext,
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): boolean {
|
|
// Mutation endpoints are local-control-plane only.
|
|
const remoteAddress = req.socket.remoteAddress;
|
|
if (!isLoopbackRemoteAddress(remoteAddress)) {
|
|
ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
|
|
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
return false;
|
|
}
|
|
|
|
// If a proxy exposes client-origin headers showing a non-loopback client,
|
|
// treat this as a remote request and deny mutation.
|
|
if (hasNonLoopbackForwardedClient(req)) {
|
|
ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
|
|
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
return false;
|
|
}
|
|
|
|
const secFetchSite = firstHeaderValue(req.headers["sec-fetch-site"])?.trim().toLowerCase();
|
|
if (secFetchSite === "cross-site") {
|
|
ctx.log?.warn?.("Rejected mutation with cross-site sec-fetch-site header");
|
|
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
return false;
|
|
}
|
|
|
|
// CSRF guard: browsers send Origin/Referer on cross-site requests.
|
|
const origin = firstHeaderValue(req.headers.origin);
|
|
if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
|
|
ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
|
|
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
return false;
|
|
}
|
|
|
|
const referer = firstHeaderValue(req.headers.referer ?? req.headers.referrer);
|
|
if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
|
|
ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
|
|
sendJson(res, 403, { ok: false, error: "Forbidden" });
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ============================================================================
|
|
// HTTP Handler
|
|
// ============================================================================
|
|
|
|
export function createNostrProfileHttpHandler(
|
|
ctx: NostrProfileHttpContext,
|
|
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
|
return async (req, res) => {
|
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
|
|
// Only handle /api/channels/nostr/:accountId/profile paths
|
|
if (!url.pathname.startsWith("/api/channels/nostr/")) {
|
|
return false;
|
|
}
|
|
|
|
const accountId = parseAccountIdFromPath(url.pathname);
|
|
if (!accountId) {
|
|
return false;
|
|
}
|
|
|
|
const isImport = url.pathname.endsWith("/profile/import");
|
|
const isProfilePath = url.pathname.endsWith("/profile") || isImport;
|
|
|
|
if (!isProfilePath) {
|
|
return false;
|
|
}
|
|
|
|
// Handle different HTTP methods
|
|
try {
|
|
if (req.method === "GET" && !isImport) {
|
|
return await handleGetProfile(accountId, ctx, res);
|
|
}
|
|
|
|
if (req.method === "PUT" && !isImport) {
|
|
return await handleUpdateProfile(accountId, ctx, req, res);
|
|
}
|
|
|
|
if (req.method === "POST" && isImport) {
|
|
return await handleImportProfile(accountId, ctx, req, res);
|
|
}
|
|
|
|
// Method not allowed
|
|
sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
return true;
|
|
} catch (err) {
|
|
ctx.log?.error(`Profile HTTP error: ${String(err)}`);
|
|
sendJson(res, 500, { ok: false, error: "Internal server error" });
|
|
return true;
|
|
}
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// GET /api/channels/nostr/:accountId/profile
|
|
// ============================================================================
|
|
|
|
async function handleGetProfile(
|
|
accountId: string,
|
|
ctx: NostrProfileHttpContext,
|
|
res: ServerResponse,
|
|
): Promise<true> {
|
|
const configProfile = ctx.getConfigProfile(accountId);
|
|
const publishState = await getNostrProfileState(accountId);
|
|
|
|
sendJson(res, 200, {
|
|
ok: true,
|
|
profile: configProfile ?? null,
|
|
publishState: publishState ?? null,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// ============================================================================
|
|
// PUT /api/channels/nostr/:accountId/profile
|
|
// ============================================================================
|
|
|
|
async function handleUpdateProfile(
|
|
accountId: string,
|
|
ctx: NostrProfileHttpContext,
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): Promise<true> {
|
|
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
return true;
|
|
}
|
|
|
|
// Rate limiting
|
|
if (!checkRateLimit(accountId)) {
|
|
sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
|
|
return true;
|
|
}
|
|
|
|
// Parse body
|
|
let body: unknown;
|
|
try {
|
|
body = await readJsonBody(req);
|
|
} catch (err) {
|
|
sendJson(res, 400, { ok: false, error: String(err) });
|
|
return true;
|
|
}
|
|
|
|
// Validate profile
|
|
const parseResult = ProfileUpdateSchema.safeParse(body);
|
|
if (!parseResult.success) {
|
|
const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
|
sendJson(res, 400, { ok: false, error: "Validation failed", details: errors });
|
|
return true;
|
|
}
|
|
|
|
const profile = parseResult.data;
|
|
|
|
// SSRF check for picture URL
|
|
if (profile.picture) {
|
|
const pictureCheck = validateUrlSafety(profile.picture);
|
|
if (!pictureCheck.ok) {
|
|
sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` });
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// SSRF check for banner URL
|
|
if (profile.banner) {
|
|
const bannerCheck = validateUrlSafety(profile.banner);
|
|
if (!bannerCheck.ok) {
|
|
sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` });
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// SSRF check for website URL
|
|
if (profile.website) {
|
|
const websiteCheck = validateUrlSafety(profile.website);
|
|
if (!websiteCheck.ok) {
|
|
sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` });
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Merge with existing profile to preserve unknown fields
|
|
const existingProfile = ctx.getConfigProfile(accountId) ?? {};
|
|
const mergedProfile: NostrProfile = {
|
|
...existingProfile,
|
|
...profile,
|
|
};
|
|
|
|
// Publish with mutex to prevent concurrent publishes
|
|
try {
|
|
const result = await withPublishLock(accountId, async () => {
|
|
return await publishNostrProfile(accountId, mergedProfile);
|
|
});
|
|
|
|
// Only persist if at least one relay succeeded
|
|
if (result.successes.length > 0) {
|
|
await ctx.updateConfigProfile(accountId, mergedProfile);
|
|
ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`);
|
|
} else {
|
|
ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`);
|
|
}
|
|
|
|
sendJson(res, 200, {
|
|
ok: true,
|
|
eventId: result.eventId,
|
|
createdAt: result.createdAt,
|
|
successes: result.successes,
|
|
failures: result.failures,
|
|
persisted: result.successes.length > 0,
|
|
});
|
|
} catch (err) {
|
|
ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`);
|
|
sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` });
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ============================================================================
|
|
// POST /api/channels/nostr/:accountId/profile/import
|
|
// ============================================================================
|
|
|
|
async function handleImportProfile(
|
|
accountId: string,
|
|
ctx: NostrProfileHttpContext,
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): Promise<true> {
|
|
if (!enforceLoopbackMutationGuards(ctx, req, res)) {
|
|
return true;
|
|
}
|
|
|
|
// Get account info
|
|
const accountInfo = ctx.getAccountInfo(accountId);
|
|
if (!accountInfo) {
|
|
sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` });
|
|
return true;
|
|
}
|
|
|
|
const { pubkey, relays } = accountInfo;
|
|
|
|
if (!pubkey) {
|
|
sendJson(res, 400, { ok: false, error: "Account has no public key configured" });
|
|
return true;
|
|
}
|
|
|
|
// Parse options from body
|
|
let autoMerge = false;
|
|
try {
|
|
const body = await readJsonBody(req);
|
|
if (typeof body === "object" && body !== null) {
|
|
autoMerge = (body as { autoMerge?: boolean }).autoMerge === true;
|
|
}
|
|
} catch {
|
|
// Ignore body parse errors - use defaults
|
|
}
|
|
|
|
ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`);
|
|
|
|
// Import from relays
|
|
const result = await importProfileFromRelays({
|
|
pubkey,
|
|
relays,
|
|
timeoutMs: 10_000, // 10 seconds for import
|
|
});
|
|
|
|
if (!result.ok) {
|
|
sendJson(res, 200, {
|
|
ok: false,
|
|
error: result.error,
|
|
relaysQueried: result.relaysQueried,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// If autoMerge is requested, merge and save
|
|
if (autoMerge && result.profile) {
|
|
const localProfile = ctx.getConfigProfile(accountId);
|
|
const merged = mergeProfiles(localProfile, result.profile);
|
|
await ctx.updateConfigProfile(accountId, merged);
|
|
ctx.log?.info(`[${accountId}] Profile imported and merged`);
|
|
|
|
sendJson(res, 200, {
|
|
ok: true,
|
|
imported: result.profile,
|
|
merged,
|
|
saved: true,
|
|
event: result.event,
|
|
sourceRelay: result.sourceRelay,
|
|
relaysQueried: result.relaysQueried,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Otherwise, just return the imported profile for review
|
|
sendJson(res, 200, {
|
|
ok: true,
|
|
imported: result.profile,
|
|
saved: false,
|
|
event: result.event,
|
|
sourceRelay: result.sourceRelay,
|
|
relaysQueried: result.relaysQueried,
|
|
});
|
|
return true;
|
|
}
|