diff --git a/pages/api/[network]/add-admin.js b/pages/api/[network]/add-admin.js index dcf6b47..d0250f7 100644 --- a/pages/api/[network]/add-admin.js +++ b/pages/api/[network]/add-admin.js @@ -42,7 +42,8 @@ async function handler(req, res) { // Check API key const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - domain + domain, + network ); if (!allowedApi) { return res diff --git a/pages/api/[network]/claim-name.js b/pages/api/[network]/claim-name.js index 45cafe3..2081a67 100644 --- a/pages/api/[network]/claim-name.js +++ b/pages/api/[network]/claim-name.js @@ -41,7 +41,8 @@ async function handler(req, res) { // Check API key const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - domain + domain, + network ); if (!allowedApi) { return res diff --git a/pages/api/[network]/delete-admin.js b/pages/api/[network]/delete-admin.js index 02186e9..f99a7f2 100644 --- a/pages/api/[network]/delete-admin.js +++ b/pages/api/[network]/delete-admin.js @@ -42,7 +42,8 @@ async function handler(req, res) { // Check API key const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - domain + domain, + network ); if (!allowedApi) { return res diff --git a/pages/api/[network]/delete-name.js b/pages/api/[network]/delete-name.js index a27e162..8a815f8 100644 --- a/pages/api/[network]/delete-name.js +++ b/pages/api/[network]/delete-name.js @@ -33,10 +33,11 @@ async function handler(req, res) { } // Check API key - const adminToken = await getAdminToken(req, body.domain); + const adminToken = await getAdminToken(req, body.domain, network); const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - body.domain + body.domain, + network ); if (!allowedApi && !adminToken) { return res diff --git a/pages/api/[network]/get-names.js b/pages/api/[network]/get-names.js index ad8a937..121d761 100644 --- a/pages/api/[network]/get-names.js +++ b/pages/api/[network]/get-names.js @@ -7,7 +7,7 @@ const cors = Cors({ origin: "*", }); -async function checkApiKey(apiKey, domain) { +async function checkApiKey(apiKey, domain, network) { if (!apiKey) { return false; } @@ -15,16 +15,19 @@ async function checkApiKey(apiKey, domain) { if (!domain) { apiQuery = await sql` SELECT api_key.id, key FROM api_key - where api_key.key = ${apiKey}`; + join domain on api_key.domain_id = domain.id + where api_key.key = ${apiKey} + and domain.network = ${network}`; } else { apiQuery = await sql` SELECT api_key.id, key FROM api_key join domain on api_key.domain_id = domain.id where domain.name = ${domain} + and domain.network = ${network} and api_key.key = ${apiKey}`; } - if (apiQuery.count >= 1) { + if (apiQuery.length >= 1) { return true; } return false; @@ -64,7 +67,7 @@ async function handler(req, res) { const apiKey = headers.authorization || req.query.api_key; // Check API key - const allowedApi = await checkApiKey(apiKey, domain); + const allowedApi = await checkApiKey(apiKey, domain, network); // if apiKey exists and is not allowed, return 401 if (apiKey && !allowedApi) { return res.status(401).json({ error: "Unauthorized" }); diff --git a/pages/api/[network]/get-names.test.js b/pages/api/[network]/get-names.test.js index c2f28d2..a02c55a 100644 --- a/pages/api/[network]/get-names.test.js +++ b/pages/api/[network]/get-names.test.js @@ -49,6 +49,7 @@ describe("get-names API E2E", () => { mainnet: {}, sepolia: {}, }; + let sharedDomainIds = {}; beforeAll(async () => { await setupTestDatabase(); @@ -172,6 +173,22 @@ describe("get-names API E2E", () => { } }); + afterEach(async () => { + if (sharedDomainIds.mainnet || sharedDomainIds.sepolia) { + await sqlForTests`DELETE FROM subdomain_text_record WHERE subdomain_id IN ( + SELECT id FROM subdomain WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0}) + )`; + await sqlForTests`DELETE FROM subdomain_coin_type WHERE subdomain_id IN ( + SELECT id FROM subdomain WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0}) + )`; + await sqlForTests`DELETE FROM subdomain WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM api_key WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM brand WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM domain WHERE id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + sharedDomainIds = {}; + } + }); + afterAll(async () => { /** * Test Data Cleanup: @@ -203,6 +220,69 @@ describe("get-names API E2E", () => { await teardownTestDatabase(); }); + describe("Same domain name stays scoped to the selected network", () => { + const sharedDomain = "shared-list.eth"; + const mainnetKey = "shared-list-mainnet-key"; + const sepoliaKey = "shared-list-sepolia-key"; + + beforeEach(async () => { + const [mainnetDomain] = await sqlForTests` + INSERT INTO domain (name, network) + VALUES (${sharedDomain}, 'mainnet') + RETURNING id + `; + const [sepoliaDomain] = await sqlForTests` + INSERT INTO domain (name, network) + VALUES (${sharedDomain}, 'sepolia') + RETURNING id + `; + + sharedDomainIds = { + mainnet: mainnetDomain.id, + sepolia: sepoliaDomain.id, + }; + + await sqlForTests` + INSERT INTO api_key (domain_id, key) + VALUES (${mainnetDomain.id}, ${mainnetKey}), + (${sepoliaDomain.id}, ${sepoliaKey}) + `; + + await sqlForTests` + INSERT INTO brand (domain_id, share_with_data_providers) + VALUES (${mainnetDomain.id}, false), + (${sepoliaDomain.id}, false) + `; + + await sqlForTests` + INSERT INTO subdomain (domain_id, name, address) + VALUES (${mainnetDomain.id}, ${`main.${sharedDomain}`}, ${TEST_ADDRESSES[1]}), + (${sepoliaDomain.id}, ${`sepolia.${sharedDomain}`}, ${TEST_ADDRESSES[2]}) + `; + }); + + test("getNames_sameDomainDifferentNetworkKey_returns401", async () => { + const req = createRequest({ + method: "GET", + query: { + network: "public_v1", + domain: sharedDomain, + }, + headers: { + authorization: sepoliaKey, + }, + }); + const res = createResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(401); + expect(JSON.parse(res._getData())).toEqual({ + error: "Unauthorized", + }); + }); + }); + /** * Tests network validation: * - Empty network parameter returns 400 diff --git a/pages/api/[network]/revoke-name.js b/pages/api/[network]/revoke-name.js index c8eb868..70f9547 100644 --- a/pages/api/[network]/revoke-name.js +++ b/pages/api/[network]/revoke-name.js @@ -35,7 +35,8 @@ async function handler(req, res) { // Check API key const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - body.domain + body.domain, + network ); if (!allowedApi) { return res diff --git a/pages/api/[network]/search-names.js b/pages/api/[network]/search-names.js index e0a95a9..711cd70 100644 --- a/pages/api/[network]/search-names.js +++ b/pages/api/[network]/search-names.js @@ -32,9 +32,10 @@ async function handler(req, res) { // Check API key const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - domain + domain, + network ); - const adminToken = await getAdminToken(req, domain); + const adminToken = await getAdminToken(req, domain, network); if (!allowedApi && !adminToken) { return res.status(401).json({ error: "key error - You are not authorized to use this endpoint", diff --git a/pages/api/[network]/set-domain.js b/pages/api/[network]/set-domain.js index 512e443..1dcf8a0 100644 --- a/pages/api/[network]/set-domain.js +++ b/pages/api/[network]/set-domain.js @@ -45,7 +45,7 @@ async function handler(req, res) { .json({ error: "Domain does not exist. Please use /enable-domain." }); } - const allowedApi = await checkApiKey(apiKey, domainName); + const allowedApi = await checkApiKey(apiKey, domainName, network); if (!allowedApi) { return res .status(401) @@ -91,7 +91,7 @@ async function handler(req, res) { "contenthash", "contenthash_raw" )} - where name = ${domainName} + where id = ${domainQuery[0].id} returning id;`; // Delete existing text records diff --git a/pages/api/[network]/set-domain.test.js b/pages/api/[network]/set-domain.test.js index 3f537df..0bad228 100644 --- a/pages/api/[network]/set-domain.test.js +++ b/pages/api/[network]/set-domain.test.js @@ -26,15 +26,91 @@ const SUPPORTED_NETWORKS = [ describe("set-domain API E2E", () => { let testDomainId; + let sharedDomainIds = {}; beforeAll(async () => { await setupTestDatabase(); }); + afterEach(async () => { + if (sharedDomainIds.mainnet || sharedDomainIds.sepolia) { + await sqlForTests`DELETE FROM domain_coin_type WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM domain_text_record WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM api_key WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM domain WHERE id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + sharedDomainIds = {}; + } + }); + afterAll(async () => { await teardownTestDatabase(); }); + describe("Same domain name stays scoped to the selected network", () => { + const sharedDomain = "shared-domain.eth"; + const mainnetKey = "shared-domain-mainnet-key"; + const sepoliaKey = "shared-domain-sepolia-key"; + const originalMainnetAddress = "0x1111111111111111111111111111111111111111"; + const originalSepoliaAddress = "0x2222222222222222222222222222222222222222"; + + beforeEach(async () => { + const [mainnetDomain] = await sqlForTests` + INSERT INTO domain (name, network, address) + VALUES (${sharedDomain}, 'mainnet', ${originalMainnetAddress}) + RETURNING id + `; + const [sepoliaDomain] = await sqlForTests` + INSERT INTO domain (name, network, address) + VALUES (${sharedDomain}, 'sepolia', ${originalSepoliaAddress}) + RETURNING id + `; + + sharedDomainIds = { + mainnet: mainnetDomain.id, + sepolia: sepoliaDomain.id, + }; + + await sqlForTests` + INSERT INTO api_key (domain_id, key) + VALUES (${mainnetDomain.id}, ${mainnetKey}), + (${sepoliaDomain.id}, ${sepoliaKey}) + `; + }); + + test("setDomain_updatesOnlyTheSelectedNetwork", async () => { + const updatedMainnetAddress = + "0x9999999999999999999999999999999999999999"; + const req = createRequest({ + method: "POST", + headers: { + authorization: mainnetKey, + }, + query: { + network: "public_v1", + }, + body: { + domain: sharedDomain, + address: updatedMainnetAddress, + }, + }); + const response = createResponse(); + + await handler(req, response); + + expect(response._getStatusCode()).toBe(200); + + const [mainnetDomain] = await sqlForTests` + SELECT address FROM domain WHERE id = ${sharedDomainIds.mainnet} + `; + const [sepoliaDomain] = await sqlForTests` + SELECT address FROM domain WHERE id = ${sharedDomainIds.sepolia} + `; + + expect(mainnetDomain.address).toBe(updatedMainnetAddress); + expect(sepoliaDomain.address).toBe(originalSepoliaAddress); + }); + }); + /** * Tests network validation: * - Empty network parameter diff --git a/pages/api/[network]/set-name.js b/pages/api/[network]/set-name.js index 2874174..a885697 100644 --- a/pages/api/[network]/set-name.js +++ b/pages/api/[network]/set-name.js @@ -35,7 +35,8 @@ async function handler(req, res) { // Check API key const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - body.domain + body.domain, + network ); if (!allowedApi) { return res diff --git a/pages/api/[network]/set-name.test.js b/pages/api/[network]/set-name.test.js index 38d47ac..1cce04f 100644 --- a/pages/api/[network]/set-name.test.js +++ b/pages/api/[network]/set-name.test.js @@ -35,6 +35,7 @@ const SUPPORTED_NETWORKS = [ describe("set-name API E2E", () => { let testDomainId; + let sharedDomainIds = {}; beforeAll(async () => { await setupTestDatabase(); @@ -102,6 +103,73 @@ describe("set-name API E2E", () => { }); }); + describe("Domain isolation by network", () => { + const sharedDomain = "shared-auth.eth"; + const mainnetKey = "shared-auth-mainnet-key"; + const sepoliaKey = "shared-auth-sepolia-key"; + + beforeEach(async () => { + const [mainnetDomain] = await sqlForTests` + INSERT INTO domain (name, network, name_limit) + VALUES (${sharedDomain}, 'mainnet', ${DEFAULT_SUBDOMAIN_LIMIT}) + RETURNING id + `; + const [sepoliaDomain] = await sqlForTests` + INSERT INTO domain (name, network, name_limit) + VALUES (${sharedDomain}, 'sepolia', ${DEFAULT_SUBDOMAIN_LIMIT}) + RETURNING id + `; + + sharedDomainIds = { + mainnet: mainnetDomain.id, + sepolia: sepoliaDomain.id, + }; + + await sqlForTests` + INSERT INTO api_key (domain_id, key) + VALUES (${mainnetDomain.id}, ${mainnetKey}), + (${sepoliaDomain.id}, ${sepoliaKey}) + `; + }); + + afterEach(async () => { + await sqlForTests`DELETE FROM subdomain WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM api_key WHERE domain_id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + await sqlForTests`DELETE FROM domain WHERE id IN (${sharedDomainIds.mainnet || 0}, ${sharedDomainIds.sepolia || 0})`; + sharedDomainIds = {}; + }); + + test("setName_sameDomainDifferentNetworkKey_returns401", async () => { + const createReq = createRequest({ + method: "POST", + query: { + network: "public_v1", + }, + headers: { + authorization: sepoliaKey, + }, + body: { + domain: sharedDomain, + name: "cross-network", + address: "0x1234567890123456789012345678901234567890", + }, + }); + const response = createResponse(); + + await handler(createReq, response); + + expect(response._getStatusCode()).toBe(401); + expect(JSON.parse(response._getData())).toEqual({ + error: "You are not authorized to use this endpoint", + }); + + const createdSubdomains = await sqlForTests` + SELECT id FROM subdomain WHERE domain_id = ${sharedDomainIds.mainnet} + `; + expect(createdSubdomains.length).toBe(0); + }); + }); + /** * Tests API functionality for each supported network: * - Mainnet (public_v1) diff --git a/pages/api/[network]/set-names.js b/pages/api/[network]/set-names.js index 4f82808..44c245a 100644 --- a/pages/api/[network]/set-names.js +++ b/pages/api/[network]/set-names.js @@ -37,7 +37,8 @@ async function handler(req, res) { // Check API key const allowedApi = await checkApiKey( headers.authorization || req.query.api_key, - body.domain + body.domain, + network ); if (!allowedApi) { return res diff --git a/pages/api/admin/set-subdomain.js b/pages/api/admin/set-subdomain.js index 8d510f1..20ec021 100644 --- a/pages/api/admin/set-subdomain.js +++ b/pages/api/admin/set-subdomain.js @@ -86,13 +86,20 @@ export default async function handler(req, res) { returning id;`; subdomainId = subdomainQuery[0].id; } else { - await sql` - update subdomain - set name = ${name}, - address = ${address}, - contenthash = ${contenthash}, - contenthash_raw = ${contenthashRaw} - where id = ${subdomainId}`; + const updatedSubdomain = await sql` + update subdomain + set name = ${name}, + address = ${address}, + contenthash = ${contenthash}, + contenthash_raw = ${contenthashRaw} + where id = ${subdomainId} and domain_id = ${body.domain_id} + returning id`; + + if (updatedSubdomain.length === 0) { + return res.status(404).json({ + error: "Subdomain not found for this domain", + }); + } } // Delete existing text records diff --git a/pages/api/admin/set-subdomain.test.js b/pages/api/admin/set-subdomain.test.js index f87d37e..5eb0f92 100644 --- a/pages/api/admin/set-subdomain.test.js +++ b/pages/api/admin/set-subdomain.test.js @@ -38,9 +38,12 @@ const TEST_ADMIN_ADDRESS = "0xAdminAddress123456789012345678901234567890"; describe("set-subdomain API E2E", () => { let testDomainId; let testSubdomainId; + let otherDomainId; + let otherSubdomainId; const testDomain = "test.eth"; const testSubdomain = "sub"; - const testFullName = `${testSubdomain}.${testDomain}`; + const otherDomain = "other.eth"; + const otherSubdomain = "other-sub"; beforeAll(async () => { await setupTestDatabase(); @@ -72,6 +75,22 @@ describe("set-subdomain API E2E", () => { `; testSubdomainId = subdomain.id; + const [foreignDomain] = await sqlForTests` + INSERT INTO domain (name, network) + VALUES (${otherDomain}, 'mainnet') + RETURNING id + `; + otherDomainId = foreignDomain.id; + + const [foreignSubdomain] = await sqlForTests` + INSERT INTO subdomain (name, domain_id, address) + VALUES (${normalize( + otherSubdomain + )}, ${otherDomainId}, '0x9999999999999999999999999999999999999999') + RETURNING id + `; + otherSubdomainId = foreignSubdomain.id; + // Mock admin token getAdminTokenById.mockResolvedValue({ sub: TEST_ADMIN_ADDRESS, @@ -82,8 +101,12 @@ describe("set-subdomain API E2E", () => { // Clean up test data await sqlForTests`DELETE FROM subdomain_text_record WHERE subdomain_id = ${testSubdomainId}`; await sqlForTests`DELETE FROM subdomain_coin_type WHERE subdomain_id = ${testSubdomainId}`; + await sqlForTests`DELETE FROM subdomain_text_record WHERE subdomain_id = ${otherSubdomainId}`; + await sqlForTests`DELETE FROM subdomain_coin_type WHERE subdomain_id = ${otherSubdomainId}`; await sqlForTests`DELETE FROM subdomain WHERE domain_id = ${testDomainId}`; + await sqlForTests`DELETE FROM subdomain WHERE domain_id = ${otherDomainId}`; await sqlForTests`DELETE FROM domain WHERE id = ${testDomainId}`; + await sqlForTests`DELETE FROM domain WHERE id = ${otherDomainId}`; }); /** @@ -372,6 +395,36 @@ describe("set-subdomain API E2E", () => { `; expect(subdomainAfterUpdate[0].address).toBe(updatedAddress); }); + + test("setSubdomain_rejectsUpdatingSubdomainOutsideAuthorizedDomain_returns404", async () => { + const foreignAddress = "0x7777777777777777777777777777777777777777"; + + const req = createRequest({ + method: "POST", + body: JSON.stringify({ + id: otherSubdomainId, + name: otherSubdomain, + domain: testDomain, + domain_id: testDomainId, + address: foreignAddress, + }), + }); + + const res = createResponse(); + await handler(req, res); + + expect(res._getStatusCode()).toBe(404); + expect(JSON.parse(res._getData())).toEqual({ + error: "Subdomain not found for this domain", + }); + + const foreignSubdomainAfterAttempt = await sqlForTests` + SELECT address FROM subdomain WHERE id = ${otherSubdomainId} + `; + expect(foreignSubdomainAfterAttempt[0].address).toBe( + "0x9999999999999999999999999999999999999999" + ); + }); }); /** diff --git a/utils/ServerUtils.js b/utils/ServerUtils.js index 116e4e1..4294b3c 100644 --- a/utils/ServerUtils.js +++ b/utils/ServerUtils.js @@ -285,7 +285,7 @@ export async function getEPSAddresses(hotAddress) { return addresses; } -export async function checkApiKey(apiKey, domain) { +export async function checkApiKey(apiKey, domain, network) { if (!apiKey) { return false; } @@ -294,18 +294,19 @@ export async function checkApiKey(apiKey, domain) { join domain on api_key.domain_id = domain.id where domain.name = ${domain} + and domain.network = ${network} and api_key.key = ${apiKey}`; - if (apiQuery.count == 1) { + if (apiQuery.length === 1) { return true; } return false; } // function to check if user is an admin of the domain -export async function getAdminToken(req, domain) { +export async function getAdminToken(req, domain, network) { const token = await getToken({ req }); - if (!token || !domain) { + if (!token || !domain || !network) { return false; } const superAdminQuery = await sql` @@ -314,7 +315,8 @@ export async function getAdminToken(req, domain) { SELECT * FROM admin join domain on admin.domain_id = domain.id WHERE admin.address = ${token.sub} - and domain.name = ${domain}`; + and domain.name = ${domain} + and domain.network = ${network}`; if (superAdminQuery.length === 0 && adminQuery.length === 0) { return false; }