1- import { test } from 'vitest' ;
1+ import { test , vi } from 'vitest' ;
22import assert from 'node:assert/strict' ;
33import crypto from 'node:crypto' ;
44import http from 'node:http' ;
5+ import { Readable } from 'node:stream' ;
56import { createDaemonProxyServer } from '../remote/daemon-proxy.ts' ;
7+ import { createDaemonHttpServer } from '../daemon/server/http-server.ts' ;
8+ import { executeRunScriptHttpRequest } from '../daemon/adapters/maestro/run-script-http-child.ts' ;
9+ import {
10+ DAEMON_HTTP_NETWORK_ACCESS_HEADER ,
11+ DAEMON_HTTP_PUBLIC_NETWORK_ACCESS ,
12+ } from '../daemon/http-contract.ts' ;
613import { DAEMON_RPC_PROTOCOL_VERSION } from '../daemon/http-health.ts' ;
714import {
815 closeLoopbackServer ,
916 listenOnLoopback ,
1017 skipWhenLoopbackUnavailable ,
1118} from './test-utils/loopback.ts' ;
1219
20+ const requestApprovedUrlMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
21+
22+ vi . mock ( '@agent-device/provision-kit/install-source-network-transport' , ( ) => ( {
23+ requestApprovedUrl : requestApprovedUrlMock ,
24+ } ) ) ;
25+
1326const PROXY_ARTIFACT_INVENTORY_ENTRY = {
1427 id : 'shot-1' ,
1528 filename : 'shot.png' ,
@@ -24,6 +37,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
2437
2538 let upstreamAuth = '' ;
2639 let upstreamTokenHeader = '' ;
40+ let upstreamNetworkAccess = '' ;
2741 let upstreamBody : Record < string , any > | undefined ;
2842 const upstream = http . createServer ( ( req , res ) => {
2943 if ( req . url === '/health' ) {
@@ -34,6 +48,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
3448 assert . equal ( req . url , '/rpc' ) ;
3549 upstreamAuth = String ( req . headers . authorization ?? '' ) ;
3650 upstreamTokenHeader = String ( req . headers [ 'x-agent-device-token' ] ?? '' ) ;
51+ upstreamNetworkAccess = String ( req . headers [ DAEMON_HTTP_NETWORK_ACCESS_HEADER ] ?? '' ) ;
3752 let body = '' ;
3853 req . setEncoding ( 'utf8' ) ;
3954 req . on ( 'data' , ( chunk ) => {
@@ -88,6 +103,7 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
88103 } ) ;
89104 assert . equal ( upstreamAuth , 'Bearer daemon-secret' ) ;
90105 assert . equal ( upstreamTokenHeader , 'daemon-secret' ) ;
106+ assert . equal ( upstreamNetworkAccess , DAEMON_HTTP_PUBLIC_NETWORK_ACCESS ) ;
91107 assert . equal ( upstreamBody ?. params ?. token , 'daemon-secret' ) ;
92108 assert . equal ( upstreamBody ?. params ?. command , 'devices' ) ;
93109 } finally {
@@ -96,6 +112,95 @@ test('daemon proxy forwards rpc requests with upstream daemon token', async (t)
96112 }
97113} ) ;
98114
115+ test ( 'proxy enforces public-only Maestro HTTP policy on a local daemon' , async ( t ) => {
116+ if ( await skipWhenLoopbackUnavailable ( t ) ) return ;
117+
118+ let loopbackRequests = 0 ;
119+ const loopbackTarget = http . createServer ( ( _req , res ) => {
120+ loopbackRequests += 1 ;
121+ res . end ( 'loopback-secret' ) ;
122+ } ) ;
123+ const env = { ...process . env } ;
124+ delete env . AGENT_DEVICE_HTTP_AUTH_HOOK ;
125+ delete env . AGENT_DEVICE_HTTP_AUTH_EXPORT ;
126+ const daemon = await createDaemonHttpServer ( {
127+ token : 'daemon-secret' ,
128+ env,
129+ handleRequest : async ( request ) => {
130+ const url = request . positionals [ 0 ] ?? '' ;
131+ return {
132+ ok : true ,
133+ data : await executeRunScriptHttpRequest ( {
134+ method : 'GET' ,
135+ url,
136+ headers : { } ,
137+ networkAccess : request . internal ?. networkAccess ?? 'unrestricted' ,
138+ } ) ,
139+ } ;
140+ } ,
141+ } ) ;
142+ const targetPort = await listenOnLoopback ( loopbackTarget ) ;
143+ const daemonPort = await listenOnLoopback ( daemon ) ;
144+ const proxy = createDaemonProxyServer ( {
145+ upstreamBaseUrl : `http://127.0.0.1:${ daemonPort } ` ,
146+ upstreamToken : 'daemon-secret' ,
147+ clientToken : 'proxy-secret' ,
148+ } ) ;
149+
150+ try {
151+ const proxyPort = await listenOnLoopback ( proxy ) ;
152+ const post = async ( url : string ) => {
153+ const response = await fetch ( `http://127.0.0.1:${ proxyPort } /agent-device/rpc` , {
154+ method : 'POST' ,
155+ headers : { 'content-type' : 'application/json' , authorization : 'Bearer proxy-secret' } ,
156+ body : JSON . stringify ( {
157+ jsonrpc : '2.0' ,
158+ id : 'proxy-trust' ,
159+ method : 'agent_device.command' ,
160+ params : {
161+ token : 'proxy-secret' ,
162+ command : 'run_script_http' ,
163+ positionals : [ url ] ,
164+ flags : { } ,
165+ } ,
166+ } ) ,
167+ } ) ;
168+ return { status : response . status , body : ( await response . json ( ) ) as Record < string , any > } ;
169+ } ;
170+
171+ const loopbackResponse = await post ( `http://127.0.0.1:${ targetPort } /secret` ) ;
172+ assert . equal ( loopbackResponse . status , 400 ) ;
173+ assert . equal ( loopbackResponse . body . error ?. data ?. code , 'INVALID_ARGS' ) ;
174+ assert . match ( loopbackResponse . body . error ?. message ?? '' , / n o n - p u b l i c a d d r e s s / ) ;
175+ assert . equal ( loopbackRequests , 0 , 'the proxy path must never reach a loopback target' ) ;
176+ assert . equal ( requestApprovedUrlMock . mock . calls . length , 0 ) ;
177+
178+ requestApprovedUrlMock . mockResolvedValue ( {
179+ statusCode : 200 ,
180+ headers : { } ,
181+ body : Readable . from ( [ 'public-response' ] ) ,
182+ close : async ( ) => { } ,
183+ } ) ;
184+ const publicUrl = 'https://93.184.216.34/public' ;
185+ const publicResponse = await post ( publicUrl ) ;
186+ assert . equal ( publicResponse . status , 200 ) ;
187+ assert . deepEqual ( publicResponse . body . result ?. data , {
188+ status : 200 ,
189+ body : 'public-response' ,
190+ headers : { } ,
191+ } ) ;
192+ assert . equal ( requestApprovedUrlMock . mock . calls . length , 1 ) ;
193+ assert . equal ( requestApprovedUrlMock . mock . calls [ 0 ] ?. [ 0 ] . url . href , publicUrl ) ;
194+ assert . equal ( requestApprovedUrlMock . mock . calls [ 0 ] ?. [ 0 ] . approvedAddress , '93.184.216.34' ) ;
195+ assert . equal ( requestApprovedUrlMock . mock . calls [ 0 ] ?. [ 0 ] . family , 4 ) ;
196+ } finally {
197+ requestApprovedUrlMock . mockReset ( ) ;
198+ await closeLoopbackServer ( proxy ) ;
199+ await closeLoopbackServer ( daemon ) ;
200+ await closeLoopbackServer ( loopbackTarget ) ;
201+ }
202+ } ) ;
203+
99204test ( 'daemon proxy rejects unauthenticated rpc requests' , async ( t ) => {
100205 if ( await skipWhenLoopbackUnavailable ( t ) ) return ;
101206
0 commit comments