@@ -49,6 +49,9 @@ const SKIP_HEADERS = {
4949// re-firing them.
5050const SAFE_REPLAY_METHODS = { GET : 1 , HEAD : 1 , OPTIONS : 1 } ;
5151
52+ // Compiled once to avoid re-parsing per request in the relay hot path.
53+ const URL_RE = / ^ h t t p s ? : \/ \/ / i;
54+
5255// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style
5356// placeholder page — no proxy-shaped JSON, nothing distinctive enough
5457// for a probe to fingerprint as a tunnel endpoint.
@@ -72,11 +75,15 @@ function _decoyOrError(jsonBody) {
7275// does its own DoH lookup on a miss from inside Google's network.
7376// Cache hits never reach the tunnel-node.
7477//
75- // Safety property: any failure (parse error, DoH unreachable,
76- // CacheService error, refused qtype) returns null from _edgeDnsTry,
77- // and the op falls through to the existing tunnel-node forward path.
78- // Set false to disable and forward all DNS through the tunnel as
79- // before.
78+ // Safety property: parse errors, refused qtypes, and "every DoH resolver
79+ // failed" return null from _edgeDnsResolve and the op falls through to
80+ // the existing tunnel-node forward path. CacheService failures (transient
81+ // quota, getAll exceptions, oversize keys) are softer: the per-batch
82+ // cache lookup is skipped and no put happens, but DoH still runs from
83+ // inside Google's network. The per-op outcome degrades to "uncached
84+ // forward via DoH" rather than "forwarded all the way to the tunnel-node".
85+ // Set ENABLE_EDGE_DNS_CACHE=false to disable the whole feature and route
86+ // all DNS through the tunnel as before.
8087const ENABLE_EDGE_DNS_CACHE = true ;
8188
8289// DoH endpoints tried in order on cache miss. All speak RFC 8484
@@ -96,8 +103,9 @@ const EDGE_DNS_MAX_TTL_S = 21600; // 6h CacheService ceiling
96103const EDGE_DNS_NEG_TTL_S = 45 ;
97104const EDGE_DNS_CACHE_PREFIX = "edns:" ;
98105// CacheService rejects keys longer than 250 chars. Names approaching the
99- // 253-char DNS limit + prefix + qtype digits can exceed that, so we bail
100- // before issuing the get/put. The op falls through to the tunnel-node.
106+ // 253-char DNS limit + prefix + qtype digits can exceed that, so keys
107+ // over this length get switched to a SHA-256-hashed form (see
108+ // _edgeDnsPrepare) rather than skipping the cache entirely.
101109const EDGE_DNS_MAX_KEY_LEN = 240 ;
102110
103111// qtypes we refuse to cache and pass through to the tunnel-node:
@@ -183,6 +191,15 @@ function _doTunnel(req) {
183191// Batch tunnel: forward all ops in one request to /tunnel/batch.
184192// When ENABLE_EDGE_DNS_CACHE is true, udp_open/port=53 ops are served
185193// locally where possible and only the remainder is forwarded.
194+ //
195+ // Edge-DNS resolution runs in two passes so the CacheService backend
196+ // is hit exactly once for the whole batch:
197+ // pass 1: parse each candidate's question and collect cache keys
198+ // one cache.getAll(keys) call serves every hit
199+ // pass 2: resolve each candidate (cache hit → synth; miss → DoH; null
200+ // → tunnel-node forward)
201+ // On a 5-DNS-query batch, this collapses 5 serial cache.get round trips
202+ // into one cache.getAll round trip.
186203function _doTunnelBatch ( req ) {
187204 var ops = ( req && req . ops ) || [ ] ;
188205
@@ -195,19 +212,59 @@ function _doTunnelBatch(req) {
195212 var forwardOps = [ ] ;
196213 var forwardIdx = [ ] ;
197214
215+ // Pass 1: route non-DNS ops to forward, parse DNS candidates.
216+ var candidates = [ ] ; // [{ i, prep }, ...]
198217 for ( var i = 0 ; i < ops . length ; i ++ ) {
199218 var op = ops [ i ] ;
200219 if ( op && op . op === "udp_open" && op . port === 53 && op . d ) {
201- var synth = _edgeDnsTry ( op ) ;
202- if ( synth ) {
203- results [ i ] = synth ;
220+ var prep = _edgeDnsPrepare ( op ) ;
221+ if ( prep ) {
222+ candidates . push ( { i : i , prep : prep } ) ;
204223 continue ;
205224 }
206225 }
207226 forwardOps . push ( op ) ;
208227 forwardIdx . push ( i ) ;
209228 }
210229
230+ // One batched cache lookup for every DNS candidate. CacheService.getAll
231+ // returns a {key: value} map populated only for hits; missing keys are
232+ // simply absent. Any failure (transient quota, backend hiccup) returns
233+ // an empty map so each candidate falls through to its own DoH attempt
234+ // with no cached put either — the safe degradation path.
235+ var cacheMap = { } ;
236+ var cache = null ;
237+ if ( candidates . length > 0 ) {
238+ try {
239+ cache = CacheService . getScriptCache ( ) ;
240+ var keys = new Array ( candidates . length ) ;
241+ for ( var c = 0 ; c < candidates . length ; c ++ ) {
242+ keys [ c ] = candidates [ c ] . prep . key ;
243+ }
244+ cacheMap = cache . getAll ( keys ) || { } ;
245+ } catch ( _ ) {
246+ cacheMap = { } ;
247+ cache = null ;
248+ }
249+ }
250+
251+ // Pass 2: resolve each candidate. cacheMap doubles as the in-batch dedup
252+ // table — a successful DoH writes its encoded reply back into cacheMap
253+ // so a later candidate with the same qname/qtype hits without re-DoH.
254+ // On null (cache miss + DoH all failed), append to the forward path so
255+ // the tunnel-node still gets a chance.
256+ for ( var c = 0 ; c < candidates . length ; c ++ ) {
257+ var cand = candidates [ c ] ;
258+ var synth = _edgeDnsResolve (
259+ cand . prep , cacheMap [ cand . prep . key ] || null , cache , cacheMap ) ;
260+ if ( synth ) {
261+ results [ cand . i ] = synth ;
262+ } else {
263+ forwardOps . push ( ops [ cand . i ] ) ;
264+ forwardIdx . push ( cand . i ) ;
265+ }
266+ }
267+
211268 // All ops served locally — no tunnel-node round-trip.
212269 if ( forwardOps . length === 0 ) {
213270 return _json ( { r : results } ) ;
@@ -279,7 +336,7 @@ function _spliceTunnelResults(forwardIdx, forwardedResults, allResults) {
279336// ========================== HTTP relay mode ==========================
280337
281338function _doSingle ( req ) {
282- if ( ! req . u || typeof req . u !== "string" || ! req . u . match ( / ^ h t t p s ? : \/ \/ / i ) ) {
339+ if ( ! req . u || typeof req . u !== "string" || ! URL_RE . test ( req . u ) ) {
283340 return _json ( { e : "bad url" } ) ;
284341 }
285342 var opts = _buildOpts ( req ) ;
@@ -302,7 +359,7 @@ function _doBatch(items) {
302359 errorMap [ i ] = "bad item" ;
303360 continue ;
304361 }
305- if ( ! item . u || typeof item . u !== "string" || ! item . u . match ( / ^ h t t p s ? : \/ \/ / i ) ) {
362+ if ( ! item . u || typeof item . u !== "string" || ! URL_RE . test ( item . u ) ) {
306363 errorMap [ i ] = "bad url" ;
307364 continue ;
308365 }
@@ -403,12 +460,20 @@ function _buildOpts(req) {
403460 return opts ;
404461}
405462
463+ // Lazy module-level cache of the runtime feature check; reset between GAS
464+ // executions but reused across all responses inside a single execution
465+ // (batches of 50+ make this matter).
466+ var _hasGetAllHeaders = null ;
467+
406468function _respHeaders ( resp ) {
407- try {
408- if ( typeof resp . getAllHeaders === "function" ) {
469+ if ( _hasGetAllHeaders === null ) {
470+ _hasGetAllHeaders = ( typeof resp . getAllHeaders === "function" ) ;
471+ }
472+ if ( _hasGetAllHeaders ) {
473+ try {
409474 return resp . getAllHeaders ( ) ;
410- }
411- } catch ( err ) { }
475+ } catch ( err ) { }
476+ }
412477 return resp . getHeaders ( ) ;
413478}
414479
@@ -433,31 +498,54 @@ function _json(obj) {
433498
434499// ========================== Edge DNS helpers ==========================
435500
436- // Tries to serve a single udp_open DNS op from CacheService or DoH.
437- // Returns a synthesized batch-result {sid, pkts, eof} on success, or null
438- // on any failure / unsupported case so the caller can forward to the
439- // tunnel-node. Null is the safe default — every error path returns null.
440- function _edgeDnsTry ( op ) {
501+ // Phase-1 helper: parses a udp_open op into the data needed for both the
502+ // batched cache lookup and the eventual resolve. Returns {bytes, q, key}
503+ // on success, or null for unparseable/refused ops so the caller can route
504+ // them to the tunnel-node forward path.
505+ //
506+ // Long qnames that would exceed CacheService's 250-char key limit fall back
507+ // to a SHA-256-hashed key under a separate `edns:h:` namespace. The
508+ // 256-bit digest makes accidental collisions astronomically unlikely, and
509+ // the distinct namespace prevents short-name keys from colliding with
510+ // hashed long-name keys.
511+ function _edgeDnsPrepare ( op ) {
441512 try {
442513 var bytes = Utilities . base64Decode ( op . d ) ;
443514 if ( ! bytes || bytes . length < 12 ) return null ;
444-
445515 var q = _dnsParseQuestion ( bytes ) ;
446516 if ( ! q ) return null ;
447517 if ( EDGE_DNS_REFUSE_QTYPES [ q . qtype ] ) return null ;
448-
449518 var key = EDGE_DNS_CACHE_PREFIX + q . qtype + ":" + q . qname ;
450- if ( key . length > EDGE_DNS_MAX_KEY_LEN ) return null ;
451- var cache = CacheService . getScriptCache ( ) ;
519+ if ( key . length > EDGE_DNS_MAX_KEY_LEN ) {
520+ key = EDGE_DNS_CACHE_PREFIX + "h:" + q . qtype + ":" + _sha256Hex ( q . qname ) ;
521+ }
522+ return { bytes : bytes , q : q , key : key } ;
523+ } catch ( _ ) {
524+ return null ;
525+ }
526+ }
452527
453- var stored = null ;
454- try { stored = cache . get ( key ) ; } catch ( _ ) { }
455- if ( stored ) {
528+ // Phase-2 helper: given a prepared op and an optional pre-fetched cache
529+ // value, returns a synthesized batch-result {sid, pkts, eof} on success,
530+ // or null on any failure so the caller can forward to the tunnel-node.
531+ //
532+ // `cache` is the CacheService handle reused across the batch (or null
533+ // if CacheService is unavailable, in which case DoH still runs
534+ // but no put).
535+ // `localMap` is an optional in-batch lookup table (typically the same
536+ // object returned by cache.getAll). When DoH succeeds, the
537+ // encoded reply is written back to localMap[prep.key] so that
538+ // a later candidate in the same batch with the same qname/qtype
539+ // hits without a second DoH round-trip.
540+ function _edgeDnsResolve ( prep , cachedReplyB64 , cache , localMap ) {
541+ try {
542+ if ( cachedReplyB64 ) {
456543 try {
457- var hit = Utilities . base64Decode ( stored ) ;
544+ var hit = Utilities . base64Decode ( cachedReplyB64 ) ;
458545 if ( hit && hit . length >= 12 ) {
459- // Rewrite txid to match this query (RFC 1035 §4.1.1).
460- var rewritten = _dnsRewriteTxid ( hit , q . txid ) ;
546+ // Rewrite txid to match this query (RFC 1035 §4.1.1). Returns a
547+ // copy so the cached bytes themselves are never mutated.
548+ var rewritten = _dnsRewriteTxid ( hit , prep . q . txid ) ;
461549 return {
462550 sid : "edns-cache" ,
463551 pkts : [ Utilities . base64Encode ( rewritten ) ] ,
@@ -468,7 +556,7 @@ function _edgeDnsTry(op) {
468556 }
469557
470558 for ( var i = 0 ; i < EDGE_DNS_RESOLVERS . length ; i ++ ) {
471- var reply = _edgeDnsDoh ( EDGE_DNS_RESOLVERS [ i ] , bytes ) ;
559+ var reply = _edgeDnsDoh ( EDGE_DNS_RESOLVERS [ i ] , prep . bytes ) ;
472560 if ( ! reply ) continue ;
473561
474562 var rcode = reply [ 3 ] & 0x0F ;
@@ -482,15 +570,24 @@ function _edgeDnsTry(op) {
482570 if ( ttl > EDGE_DNS_MAX_TTL_S ) ttl = EDGE_DNS_MAX_TTL_S ;
483571 }
484572
485- try {
486- cache . put ( key , Utilities . base64Encode ( reply ) , ttl ) ;
487- } catch ( _ ) {
488- // >100KB value or transient quota — still return the live answer.
573+ // Encode once and reuse for both the persistent cache and the
574+ // in-batch dedup map. The reply bytes carry the resolver-echoed
575+ // txid; any future hit rewrites it to that request's txid.
576+ var encoded = ( cache || localMap ) ? Utilities . base64Encode ( reply ) : null ;
577+ if ( cache ) {
578+ try {
579+ cache . put ( prep . key , encoded , ttl ) ;
580+ } catch ( _ ) {
581+ // >100KB value or transient quota — still return the live answer.
582+ }
583+ }
584+ if ( localMap ) {
585+ localMap [ prep . key ] = encoded ;
489586 }
490587
491588 // The DoH reply already echoes our query's txid; rewrite defensively
492589 // in case a resolver mangles it.
493- var fixed = _dnsRewriteTxid ( reply , q . txid ) ;
590+ var fixed = _dnsRewriteTxid ( reply , prep . q . txid ) ;
494591 return {
495592 sid : "edns-doh" ,
496593 pkts : [ Utilities . base64Encode ( fixed ) ] ,
@@ -503,6 +600,22 @@ function _edgeDnsTry(op) {
503600 }
504601}
505602
603+ // Hex-encodes the SHA-256 of a UTF-8 string. Used to keep long-qname cache
604+ // keys under CacheService's 250-char limit. 64 hex chars is well below the
605+ // cap and survives any future bumps to EDGE_DNS_MAX_KEY_LEN. SHA-256 over
606+ // MD5 here is just future-proofing — the hash isn't security-sensitive
607+ // (cache namespace only), but SHA-256 avoids any "why MD5?" discussion.
608+ function _sha256Hex ( s ) {
609+ var d = Utilities . computeDigest (
610+ Utilities . DigestAlgorithm . SHA_256 , s , Utilities . Charset . UTF_8 ) ;
611+ var hex = "" ;
612+ for ( var i = 0 ; i < d . length ; i ++ ) {
613+ var b = d [ i ] & 0xFF ;
614+ hex += ( b < 16 ? "0" : "" ) + b . toString ( 16 ) ;
615+ }
616+ return hex ;
617+ }
618+
506619// Single DoH GET against `url`. Returns the reply as a byte array, or null
507620// on any failure (HTTP non-200, network error, malformed body).
508621function _edgeDnsDoh ( url , queryBytes ) {
@@ -623,6 +736,12 @@ function _dnsSkipName(bytes, off) {
623736// big-endian 16-bit transaction id. Coerces to signed-byte range so the
624737// result round-trips through Utilities.base64Encode regardless of whether
625738// the runtime exposes bytes as signed Java int8 or unsigned JS numbers.
739+ //
740+ // Always copies — the cache-safety invariant (callers can hand in a buffer
741+ // they may reuse, e.g. a CacheService string round-tripped through decode)
742+ // is enforced here rather than via per-call-site reasoning. The copy is
743+ // cheap (~100 bytes for a typical DNS reply) compared to the surrounding
744+ // base64 encode/decode work.
626745function _dnsRewriteTxid ( bytes , txid ) {
627746 var out = [ ] ;
628747 for ( var i = 0 ; i < bytes . length ; i ++ ) out . push ( bytes [ i ] ) ;
0 commit comments