Skip to content

Commit df62a82

Browse files
authored
fix(proxy): normalize public request URLs
- normalize public request URLs once at the app boundary - prefer PUBLIC_BASE_URL over trusted forwarded headers - restore the upstream x402 Hono middleware and cover the behavior with tests
1 parent f580727 commit df62a82

File tree

10 files changed

+356
-8
lines changed

10 files changed

+356
-8
lines changed

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ DATABASE_PATH=./data/tack.db
1414
DELEGATE_URL=http://localhost:8080/ipfs
1515

1616
# Networking / security
17+
# Recommended in production so the AgentCard and x402 metadata always use your
18+
# canonical public HTTPS origin, even if proxy headers are missing or partial.
19+
PUBLIC_BASE_URL=
1720
# When false, rate limiting ignores forwarded headers and uses socket remote address.
18-
# Set true only when running behind a trusted reverse proxy that sets x-forwarded-for/x-real-ip.
21+
# Set true only when running behind a trusted reverse proxy that sets forwarded client IP/host/proto headers.
1922
TRUST_PROXY=false
2023
# Optional secret for signed wallet bearer tokens on owner pin endpoints.
2124
# Set this in production to enable `Authorization: Bearer <token>` auth for /pins owner operations.

docs/railway-deployment.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Set these in Railway Variables.
4141
| `IPFS_API_URL` | `http://tack-ipfs.railway.internal:5001` | Use your internal service hostname from Railway networking. |
4242
| `DATABASE_PATH` | `/app/data/tack.db` | Must be on the mounted persistent volume. |
4343
| `DELEGATE_URL` | `https://<ipfs-gateway-domain>/ipfs` | Returned in pin delegates metadata. |
44+
| `PUBLIC_BASE_URL` | `https://<your-api-domain>` | Recommended. Used for AgentCard and x402 absolute resource URLs. |
4445
| `TRUST_PROXY` | `true` | Railway terminates edge traffic before your service. |
4546
| `WALLET_AUTH_TOKEN_SECRET` | long random secret | Required for owner `Authorization: Bearer` flows. |
4647
| `X402_ENABLED` | `true` | Required for production startup checks. |
@@ -101,11 +102,12 @@ When a release causes issues:
101102
3. `X402_ENABLED=true` and `X402_NETWORK=eip155:167000`.
102103
4. `X402_PAY_TO` and `X402_USDC_ASSET_ADDRESS` are real Taiko Alethia addresses.
103104
5. `WALLET_AUTH_TOKEN_SECRET` is set to a strong random value.
104-
6. `TRUST_PROXY=true` configured.
105-
7. `/health` stable at `200` over repeated checks.
106-
8. End-to-end smoke passes (`pnpm smoke:x402`) against Railway URL.
107-
9. Manual pin/list/get/delete flow works with paid wallet identity.
108-
10. Rollback owner and backup location documented before launch.
105+
6. `PUBLIC_BASE_URL` matches the public Railway HTTPS domain.
106+
7. `TRUST_PROXY=true` configured.
107+
8. `/health` stable at `200` over repeated checks.
108+
9. End-to-end smoke passes (`pnpm smoke:x402`) against Railway URL.
109+
10. Manual pin/list/get/delete flow works with paid wallet identity.
110+
11. Rollback owner and backup location documented before launch.
109111

110112
## 8) Production Limitations & Upgrade Path
111113

src/app.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
UpstreamServiceError,
1010
ValidationError
1111
} from './lib/errors';
12+
import { createExternalRequestUrlMiddleware } from './lib/request-url';
1213
import { toPinStatusResponse, type PinningService } from './services/pinning-service';
1314
import type { InMemoryRateLimiter } from './services/rate-limiter';
1415
import { logger } from './services/logger';
@@ -302,6 +303,7 @@ export interface AppServices {
302303
walletAuthTokenSecret?: string;
303304
gatewayCacheControlMaxAgeSeconds?: number;
304305
uploadMaxSizeBytes?: number;
306+
publicBaseUrl?: string;
305307
trustProxy?: boolean;
306308
rateLimiter?: InMemoryRateLimiter;
307309
healthCheck?: () => Promise<void>;
@@ -328,8 +330,14 @@ export function createApp(services: AppServices): Hono<AppEnv> {
328330
const app = new Hono<AppEnv>();
329331
const cacheControlMaxAgeSeconds = services.gatewayCacheControlMaxAgeSeconds ?? DEFAULT_GATEWAY_CACHE_CONTROL_MAX_AGE_SECONDS;
330332
const uploadMaxSizeBytes = services.uploadMaxSizeBytes ?? DEFAULT_UPLOAD_MAX_SIZE_BYTES;
333+
const publicBaseUrl = services.publicBaseUrl;
331334
const trustProxy = services.trustProxy ?? false;
332335

336+
app.use('*', createExternalRequestUrlMiddleware({
337+
publicBaseUrl,
338+
trustProxy
339+
}));
340+
333341
if (services.paymentMiddleware) {
334342
app.use(services.paymentMiddleware);
335343
}
@@ -408,8 +416,7 @@ export function createApp(services: AppServices): Hono<AppEnv> {
408416
});
409417

410418
app.get('/.well-known/agent.json', (c) => {
411-
const baseUrl = new URL(c.req.url);
412-
const origin = `${baseUrl.protocol}//${baseUrl.host}`;
419+
const origin = new URL(c.req.url).origin;
413420
const agent = services.agentCard;
414421

415422
return c.json({

src/config.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface AppConfig {
66
ipfsTimeoutMs: number;
77
dbPath: string;
88
delegateUrl: string;
9+
publicBaseUrl?: string;
910
trustProxy: boolean;
1011
walletAuthTokenSecret?: string;
1112
uploadMaxSizeBytes: number;
@@ -63,6 +64,41 @@ function parseList(value: string | undefined): string[] {
6364
.filter((item) => item.length > 0);
6465
}
6566

67+
function parsePublicBaseUrl(value: string | undefined): string | undefined {
68+
if (!value) {
69+
return undefined;
70+
}
71+
72+
const trimmed = value.trim();
73+
if (trimmed.length === 0) {
74+
return undefined;
75+
}
76+
77+
let parsed: URL;
78+
79+
try {
80+
parsed = new URL(trimmed);
81+
} catch {
82+
throw new Error('Invalid URL for PUBLIC_BASE_URL');
83+
}
84+
85+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
86+
throw new Error('PUBLIC_BASE_URL must use http or https');
87+
}
88+
89+
if (
90+
parsed.username.length > 0 ||
91+
parsed.password.length > 0 ||
92+
parsed.pathname !== '/' ||
93+
parsed.search.length > 0 ||
94+
parsed.hash.length > 0
95+
) {
96+
throw new Error('PUBLIC_BASE_URL must be an origin without path, query, or hash');
97+
}
98+
99+
return parsed.origin;
100+
}
101+
66102
function isPlaceholderEvmAddress(value: string): boolean {
67103
return PLACEHOLDER_EVM_ADDRESSES.has(value.trim().toLowerCase());
68104
}
@@ -103,6 +139,7 @@ export function getConfig(): AppConfig {
103139
ipfsTimeoutMs: parseNumber(process.env.IPFS_TIMEOUT_MS, 30000, 'IPFS_TIMEOUT_MS'),
104140
dbPath: process.env.DATABASE_PATH ?? './data/tack.db',
105141
delegateUrl: process.env.DELEGATE_URL ?? 'http://localhost:8080/ipfs',
142+
publicBaseUrl: parsePublicBaseUrl(process.env.PUBLIC_BASE_URL),
106143
trustProxy: parseBoolean(process.env.TRUST_PROXY, false),
107144
walletAuthTokenSecret: process.env.WALLET_AUTH_TOKEN_SECRET,
108145
uploadMaxSizeBytes: parseNumber(process.env.UPLOAD_MAX_SIZE_BYTES, 100 * 1024 * 1024, 'UPLOAD_MAX_SIZE_BYTES'),

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const app = createApp({
8080
walletAuthTokenSecret: config.walletAuthTokenSecret,
8181
gatewayCacheControlMaxAgeSeconds: config.gatewayCacheControlMaxAgeSeconds,
8282
uploadMaxSizeBytes: config.uploadMaxSizeBytes,
83+
publicBaseUrl: config.publicBaseUrl,
8384
trustProxy: config.trustProxy,
8485
healthCheck: async () => {
8586
await ipfsClient.id();

src/lib/request-url.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { MiddlewareHandler } from 'hono';
2+
3+
export interface ExternalRequestUrlOptions {
4+
publicBaseUrl?: string;
5+
trustProxy?: boolean;
6+
}
7+
8+
interface MutableRequestUrl {
9+
raw: Request;
10+
url: string;
11+
}
12+
13+
function getFirstHeaderValue(value: string | null): string | null {
14+
if (!value) {
15+
return null;
16+
}
17+
18+
const first = value.split(',')[0]?.trim();
19+
return first && first.length > 0 ? first : null;
20+
}
21+
22+
function stripQuotes(value: string): string {
23+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
24+
return value.slice(1, -1);
25+
}
26+
27+
return value;
28+
}
29+
30+
function isTrustedProtocol(value: string | null): value is 'http' | 'https' {
31+
return value === 'http' || value === 'https';
32+
}
33+
34+
function parseForwardedHeader(value: string | null): { host: string | null; proto: 'http' | 'https' | null } {
35+
const first = getFirstHeaderValue(value);
36+
if (!first) {
37+
return { host: null, proto: null };
38+
}
39+
40+
let host: string | null = null;
41+
let proto: 'http' | 'https' | null = null;
42+
43+
for (const part of first.split(';')) {
44+
const [rawKey, rawValue] = part.split('=', 2);
45+
if (!rawKey || !rawValue) {
46+
continue;
47+
}
48+
49+
const key = rawKey.trim().toLowerCase();
50+
const parsedValue = stripQuotes(rawValue.trim());
51+
52+
if (key === 'host' && parsedValue.length > 0) {
53+
host = parsedValue;
54+
}
55+
56+
if (key === 'proto') {
57+
const normalized = parsedValue.toLowerCase();
58+
if (isTrustedProtocol(normalized)) {
59+
proto = normalized;
60+
}
61+
}
62+
}
63+
64+
return { host, proto };
65+
}
66+
67+
function parseForwardedPort(value: string | null): string | null {
68+
const port = getFirstHeaderValue(value);
69+
return port && /^[0-9]+$/.test(port) ? port : null;
70+
}
71+
72+
function applyOrigin(url: URL, origin: URL): URL {
73+
url.protocol = origin.protocol;
74+
url.host = origin.host;
75+
return url;
76+
}
77+
78+
function applyForwardedHost(url: URL, forwardedHost: string): boolean {
79+
try {
80+
const parsed = new URL(`${url.protocol}//${forwardedHost}`);
81+
if (
82+
parsed.username.length > 0 ||
83+
parsed.password.length > 0 ||
84+
parsed.pathname !== '/' ||
85+
parsed.search.length > 0 ||
86+
parsed.hash.length > 0
87+
) {
88+
return false;
89+
}
90+
91+
url.hostname = parsed.hostname;
92+
url.port = parsed.port;
93+
return true;
94+
} catch {
95+
return false;
96+
}
97+
}
98+
99+
export function getExternalRequestUrl(requestUrl: string, headers: Headers, options?: ExternalRequestUrlOptions): URL {
100+
const url = new URL(requestUrl);
101+
102+
if (options?.publicBaseUrl) {
103+
return applyOrigin(url, new URL(options.publicBaseUrl));
104+
}
105+
106+
if (!options?.trustProxy) {
107+
return url;
108+
}
109+
110+
const forwarded = parseForwardedHeader(headers.get('forwarded'));
111+
const forwardedHost = forwarded.host ?? getFirstHeaderValue(headers.get('x-forwarded-host'));
112+
const forwardedPort = parseForwardedPort(headers.get('x-forwarded-port'));
113+
const forwardedProto = forwarded.proto ?? (() => {
114+
const proto = getFirstHeaderValue(headers.get('x-forwarded-proto'))?.toLowerCase() ?? null;
115+
return isTrustedProtocol(proto) ? proto : null;
116+
})();
117+
118+
if (forwardedHost) {
119+
const appliedHost = applyForwardedHost(url, forwardedHost);
120+
if (appliedHost && forwardedPort && url.port.length === 0) {
121+
url.port = forwardedPort;
122+
}
123+
} else if (forwardedPort) {
124+
url.port = forwardedPort;
125+
}
126+
127+
if (forwardedProto) {
128+
url.protocol = `${forwardedProto}:`;
129+
}
130+
131+
return url;
132+
}
133+
134+
export function normalizeExternalRequestUrl(request: MutableRequestUrl, headers: Headers, options?: ExternalRequestUrlOptions): string {
135+
const externalUrl = getExternalRequestUrl(request.url, headers, options).toString();
136+
137+
if (externalUrl !== request.url) {
138+
Object.defineProperty(request.raw, 'url', {
139+
value: externalUrl,
140+
configurable: true,
141+
writable: true
142+
});
143+
}
144+
145+
return externalUrl;
146+
}
147+
148+
export function createExternalRequestUrlMiddleware(options?: ExternalRequestUrlOptions): MiddlewareHandler {
149+
return async (c, next) => {
150+
normalizeExternalRequestUrl(c.req, c.req.raw.headers, options);
151+
await next();
152+
};
153+
}

tests/integration/app.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,16 +334,59 @@ describe('API integration', () => {
334334
expect(response.status).toBe(200);
335335

336336
const agentCard = (await response.json()) as {
337+
endpoint: string;
337338
protocol: string;
338339
capabilities: { pinningApi: { endpoints: string[] } };
339340
pricing: { retrieval: { metadataField: string } };
340341
};
341342

342343
expect(agentCard.protocol).toBe('a2a');
344+
expect(agentCard.endpoint).toBe('http://localhost');
343345
expect(agentCard.capabilities.pinningApi.endpoints).toContain('/pins');
344346
expect(agentCard.pricing.retrieval.metadataField).toBe('meta.retrievalPrice');
345347
});
346348

349+
it('uses forwarded host and proto in the AgentCard when trustProxy is enabled', async () => {
350+
const trustedProxyApp = createApp({
351+
pinningService: service,
352+
trustProxy: true
353+
});
354+
355+
const response = await trustedProxyApp.request(
356+
new Request('http://localhost/.well-known/agent.json', {
357+
headers: {
358+
'x-forwarded-host': 'tack-api-production.up.railway.app',
359+
'x-forwarded-proto': 'https'
360+
}
361+
})
362+
);
363+
364+
expect(response.status).toBe(200);
365+
const agentCard = (await response.json()) as { endpoint: string };
366+
expect(agentCard.endpoint).toBe('https://tack-api-production.up.railway.app');
367+
});
368+
369+
it('prefers PUBLIC_BASE_URL for the AgentCard over forwarded headers', async () => {
370+
const publicBaseUrlApp = createApp({
371+
pinningService: service,
372+
publicBaseUrl: 'https://api.tack.example',
373+
trustProxy: true
374+
});
375+
376+
const response = await publicBaseUrlApp.request(
377+
new Request('http://localhost/.well-known/agent.json', {
378+
headers: {
379+
'x-forwarded-host': 'internal-only.example',
380+
'x-forwarded-proto': 'http'
381+
}
382+
})
383+
);
384+
385+
expect(response.status).toBe(200);
386+
const agentCard = (await response.json()) as { endpoint: string };
387+
expect(agentCard.endpoint).toBe('https://api.tack.example');
388+
});
389+
347390
it('rejects uploads over configured upload size limit', async () => {
348391
const strictApp = createApp({ pinningService: service, uploadMaxSizeBytes: 4 });
349392
const form = new FormData();

tests/unit/config.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@ describe('config validation', () => {
7878
expect(config.pinReplicaDelegateUrls).toEqual(['https://gw-a.example/ipfs', 'https://gw-b.example/ipfs']);
7979
});
8080

81+
it('parses PUBLIC_BASE_URL as an origin', () => {
82+
setTestEnv({
83+
PUBLIC_BASE_URL: 'https://api.tack.example/'
84+
});
85+
86+
const config = getConfig();
87+
expect(config.publicBaseUrl).toBe('https://api.tack.example');
88+
});
89+
8190
it('fails when replica delegate URLs do not match replica API URL count', () => {
8291
setTestEnv({
8392
PIN_REPLICA_IPFS_API_URLS: 'http://ipfs-a:5001,http://ipfs-b:5001',
@@ -88,4 +97,12 @@ describe('config validation', () => {
8897
'PIN_REPLICA_DELEGATE_URLS must have the same number of entries as PIN_REPLICA_IPFS_API_URLS'
8998
);
9099
});
100+
101+
it('rejects PUBLIC_BASE_URL values with paths', () => {
102+
setTestEnv({
103+
PUBLIC_BASE_URL: 'https://api.tack.example/v1'
104+
});
105+
106+
expect(() => getConfig()).toThrow('PUBLIC_BASE_URL must be an origin without path, query, or hash');
107+
});
91108
});

0 commit comments

Comments
 (0)