Skip to content
Open
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 .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
VITE_SOROBAN_RPC_URL: https://soroban-testnet.stellar.org
VITE_CONTRACT_ID: PLACEHOLDER_CONTRACT_ID
VITE_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015"
VITE_INDEXER_URL: http://localhost:3001

- name: Upload Playwright report
uses: actions/upload-artifact@v7
Expand Down
12 changes: 12 additions & 0 deletions .run-ipfs-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Temporary helper — remove after tests run
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT/bimex-frontend"
test -d node_modules || npm install
npm run test:run -- src/test/ipfs.test.js
echo "FRONTEND_EXIT:$?"
cd "$ROOT/bimex-indexer"
test -d node_modules || npm install
npm test -- tests/ipfsProxy.test.js
echo "INDEXER_EXIT:$?"
125 changes: 125 additions & 0 deletions bimex-frontend/e2e/ipfs-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { test, expect, type Page } from '@playwright/test'
import { mockFreighterConnected } from './fixtures/freighter'

const INDEXER = 'http://localhost:3001'
const MOCK_CID = 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'

const MIN_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
)

const MIN_PDF = Buffer.from(
'%PDF-1.1\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n',
'utf8',
)

async function seedWalletSession(page: Page) {
await page.addInitScript(() => {
sessionStorage.setItem('bimex.wallet.session', '1')
localStorage.setItem('bimex.tour.completed', 'true')
})
}

async function stubIndexerReads(page: Page) {
await page.route(`${INDEXER}/**`, async (route) => {
const req = route.request()
const url = req.url()

if (req.method() === 'GET' && url.includes('/proyectos')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
})
return
}

// Let a later, more specific /ipfs-upload handler fulfill uploads (Playwright LIFO).
if (url.includes('/ipfs-upload')) {
await route.fallback()
return
}

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
})
})
}

test.describe('IPFS upload via indexer proxy (#145)', () => {
test('create-project docs upload hits /ipfs-upload, never api.pinata.cloud', async ({ page }) => {
const pinataHits: string[] = []
const uploadBodies: unknown[] = []

await mockFreighterConnected(page)
await seedWalletSession(page)

// Broader stub first; more specific /ipfs-upload route registered after wins (LIFO).
await stubIndexerReads(page)

await page.route('**/api.pinata.cloud/**', async (route) => {
pinataHits.push(route.request().url())
await route.abort()
})

await page.route(`${INDEXER}/ipfs-upload`, async (route) => {
const req = route.request()
expect(req.method()).toBe('POST')
expect(req.headers()['content-type'] || '').toContain('application/json')
const body = req.postDataJSON()
uploadBodies.push(body)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ IpfsHash: MOCK_CID }),
})
})

await page.goto('/proyectos')
await expect(page.getByRole('button', { name: /crear/i }).first()).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: /crear/i }).first().click()

const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 10_000 })

await dialog.locator('#campo-nombre').fill('Proyecto E2E IPFS')
await dialog.locator('#campo-meta').fill('10000')
await dialog.locator('#campo-tiempo').fill('6')
await dialog.getByRole('button', { name: /siguiente.*documentos/i }).click()

await dialog.locator('#doc-ine').setInputFiles({
name: 'ine.png',
mimeType: 'image/png',
buffer: MIN_PNG,
})
await dialog.locator('#doc-plan').setInputFiles({
name: 'plan.pdf',
mimeType: 'application/pdf',
buffer: MIN_PDF,
})
await dialog.locator('#doc-presupuesto').setInputFiles({
name: 'presupuesto.pdf',
mimeType: 'application/pdf',
buffer: MIN_PDF,
})

const generateBtn = dialog.getByRole('button', { name: /generar huella digital/i })
await expect(generateBtn).toBeEnabled({ timeout: 10_000 })
await generateBtn.click()

await expect(dialog.getByText('Documentos en IPFS')).toBeVisible({ timeout: 15_000 })
const truncated = `${MOCK_CID.slice(0, 20)}…`
await expect(dialog.getByRole('link', { name: truncated }).first()).toBeVisible()

expect(uploadBodies.length).toBe(3)
for (const body of uploadBodies as Array<{ filename: string; mimeType: string; base64: string }>) {
expect(body.filename).toMatch(/\.(png|pdf)$/)
expect(body.mimeType).toMatch(/^(image\/png|application\/pdf)$/)
expect(body.base64.length).toBeGreaterThan(0)
}
expect(pinataHits).toEqual([])
})
})
5 changes: 5 additions & 0 deletions bimex-frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,10 @@ export default defineConfig({
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
env: {
...process.env,
// Required so subirAIPFS posts to the indexer proxy (never Pinata from the browser)
VITE_INDEXER_URL: process.env.VITE_INDEXER_URL || 'http://localhost:3001',
},
},
})
64 changes: 63 additions & 1 deletion bimex-frontend/src/test/ipfs.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
validarArchivo,
TIPOS_PERMITIDOS,
TAMANO_MAX_BYTES,
esCID,
cidAUrl,
parsearDocHash,
subirAIPFS,
subirConFallback,
} from '../utils/ipfs.js';

describe('validarArchivo', () => {
Expand Down Expand Up @@ -86,6 +88,66 @@ describe('cidAUrl', () => {
});
});

describe('subirAIPFS (proxy, no Pinata client keys)', () => {
beforeEach(() => {
vi.stubEnv('VITE_INDEXER_URL', 'http://localhost:3001');
vi.stubGlobal('fetch', vi.fn());
class MockFileReader {
result = '';
onload = null;
onerror = null;
readAsDataURL() {
this.result = 'data:application/pdf;base64,AQID';
queueMicrotask(() => this.onload?.());
}
}
vi.stubGlobal('FileReader', MockFileReader);
});

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it('posts file to indexer /ipfs-upload, not to api.pinata.cloud', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ IpfsHash: 'QmProxyHash' }),
});

const file = new File([new Uint8Array([1, 2, 3])], 'doc.pdf', { type: 'application/pdf' });
const cid = await subirAIPFS(file);

expect(cid).toBe('QmProxyHash');
expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(url).toBe('http://localhost:3001/ipfs-upload');
expect(String(url)).not.toContain('pinata.cloud');
expect(init.method).toBe('POST');
expect(init.headers['Content-Type']).toBe('application/json');
const body = JSON.parse(init.body);
expect(body.filename).toBe('doc.pdf');
expect(body.mimeType).toBe('application/pdf');
expect(body.base64).toBe('AQID');
});

it('throws when indexer URL is missing', async () => {
vi.stubEnv('VITE_INDEXER_URL', '');
const file = new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' });
await expect(subirAIPFS(file)).rejects.toThrow(/Indexer URL not configured/);
});

it('subirConFallback uses CID when proxy succeeds', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ IpfsHash: 'QmOk' }),
});
const file = new File([new Uint8Array([9])], 'a.pdf', { type: 'application/pdf' });
const res = await subirConFallback(file);
expect(res).toEqual({ cid: 'QmOk', fallbackHash: null, usedFallback: false });
});
});

describe('parsearDocHash', () => {
const CID0 = 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG';
const CID1 = 'QmPZ9gcCEpqKTo6aq61g2nXGUhM4iCL3ewB6LDXZCtioEB';
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| [Auditoría externa](AUDITORIA.md) | Reporte de auditoría del contrato y estado de hallazgos (#136) |
| [Alcance de auditoría](AUDIT-SCOPE.md) | Alcance congelado y checklist del SDF Audit Bank |
| [Bug Bounty Tier 2](SECURITY-BOUNTY-TIER2.md) | Decisión de plataforma y presupuesto para el programa post-lanzamiento |
| [Decisión: VITE_PINATA_SECRET](decision-vite-pinata-secret.md) | Auditoría #145 — secret de Pinata solo en el indexer vía `/ipfs-upload` |

## 🚀 Proyecto Piloto (Mainnet)

Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Base URL: configure with `VITE_INDEXER_URL` for the frontend and `API_PORT` for

`POST /ipfs-upload`

Server-side proxy that uploads a file to Pinata/IPFS, keeping the Pinata API secret out of the client bundle (see `bimex-indexer/ipfsProxy.js`).
Server-side proxy that uploads a file to Pinata/IPFS, keeping the Pinata API secret out of the client bundle (see `bimex-indexer/ipfsProxy.js`). Decision record: [`docs/decision-vite-pinata-secret.md`](decision-vite-pinata-secret.md) (issue #145).

### Request body (JSON)

Expand Down
46 changes: 46 additions & 0 deletions docs/decision-vite-pinata-secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Decisión: `VITE_PINATA_SECRET` → proxy server-side (#145)

## Contexto

En `.env.example` aparecía históricamente `VITE_PINATA_SECRET`. Todo lo prefijado con `VITE_` se bundlea en el JS público del frontend y queda visible a cualquier usuario.

Un JWT de Pinata puede ser:

| Tipo | ¿Seguro en el cliente? |
| --- | --- |
| Gateway-only (solo lectura de gateway) | Sí, con un nombre menos confuso |
| API secret / JWT con permiso de `pinFileToIPFS` | **No** — cualquiera puede subir archivos o quemar cuota |

## Auditoría

1. **Código actual:** el frontend (`bimex-frontend/src/utils/ipfs.js`) ya **no** llama a Pinata. Sube vía `POST ${VITE_INDEXER_URL}/ipfs-upload`.
2. **Backend:** `bimex-indexer/ipfsProxy.js` usa `PINATA_API_KEY` + `PINATA_SECRET` (sin prefijo `VITE_`) contra `https://api.pinata.cloud/pinning/pinFileToIPFS` — es decir, secretos con permiso de **upload**.
3. **Ejemplos de entorno:** `bimex-frontend/.env.example` y `.env.staging.example` documentan que Pinata vive solo en el indexer. Ver también `docs/api.md` (`POST /ipfs-upload`).

No se pudo inspeccionar el dashboard de Vercel/Pinata desde este cambio de código. La decisión se basa en el uso real en el repositorio: las keys del servidor llaman a `pinFileToIPFS`, por lo que **no** son un JWT público de gateway.

## Decisión

**Mover (y mantener) el secret en el backend.** No renombrar a `VITE_PINATA_GATEWAY_TOKEN`.

| Acción | Estado |
| --- | --- |
| Endpoint proxy `POST /ipfs-upload` en `bimex-indexer` | Implementado (`ipfsProxy.js` + rate limit) |
| Frontend llama al proxy (no a Pinata) | Implementado (`subirAIPFS` → `/ipfs-upload`) |
| Validación de tipo/tamaño | Cliente (`validarArchivo`) + servidor (`handleIpfsUpload`) |
| Fallback SHA-256 si el proxy falla | Implementado (`subirConFallback`) |
| Variables `VITE_PINATA_*` en el frontend | Eliminadas de ejemplos; no deben existir en Vercel |

## Operaciones pendientes (fuera del repo)

Mantenedores con acceso a Vercel / Pinata deben:

1. Confirmar que **no** queda `VITE_PINATA_SECRET` (ni `VITE_PINATA_API_KEY`) en el proyecto frontend de Vercel.
2. Configurar `PINATA_API_KEY` y `PINATA_SECRET` solo en el entorno del **indexer**.
3. Si alguna key con upload estuvo alguna vez en un env `VITE_*` o en un bundle público: **rotar** la key en el dashboard de Pinata y actualizar el indexer.

## Referencias

- Issue: [#145](https://github.com/David1984TK/Bimex/issues/145)
- API: [`docs/api.md`](api.md) — sección Upload IPFS
- Código: `bimex-indexer/ipfsProxy.js`, `bimex-frontend/src/utils/ipfs.js`
Loading