This is an automated email from the ASF dual-hosted git repository.

jongyoul pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new f988eef9fe [ZEPPELIN-6521] Preserve withCredentials on HTTP requests 
in production builds
f988eef9fe is described below

commit f988eef9fe9a2853988193c7572c3b01d554441c
Author: dae won <[email protected]>
AuthorDate: Fri Aug 7 12:43:40 2026 +0900

    [ZEPPELIN-6521] Preserve withCredentials on HTTP requests in production 
builds
    
    ### What is this PR for?
    
    In production builds the interceptor re-clones from the original 
`httpRequest` when it adds `X-Requested-With`, discarding the `withCredentials: 
true` clone made one line earlier. `clone()` inherits from whatever it was 
cloned from (`update.withCredentials ?? this.withCredentials`), so the 
production request goes out with the default `false`.
    
    ```diff
           let httpRequestUpdated = httpRequest.clone({ withCredentials: true 
});
           if (environment.production) {
    -        httpRequestUpdated = httpRequest.clone({ setHeaders: { 
'X-Requested-With': 'XMLHttpRequest' } });
    +        httpRequestUpdated = httpRequestUpdated.clone({ setHeaders: { 
'X-Requested-With': 'XMLHttpRequest' } });
           }
    ```
    
    Two things the ticket does not cover. First, `BaseUrlService` builds the 
REST base from `location`, so every call to Zeppelin's own API is same-origin 
and the browser attaches cookies whether or not the flag is set; the flag has 
no reachable effect on those calls today. The classic UI's equivalent service 
remaps the port when the UI is served from the grunt dev server 
(`zeppelin-web/src/components/base-url/base-url.service.js:26-29`), which is 
what made `withCredentials` meaningful th [...]
    
    ### Scope and related issues
    
    **Third-party URL fetches go through this interceptor too, and this change 
narrows what they can reach.** `NoteImportComponent` passes a user-supplied URL 
straight to `HttpClient` (`note-import.component.ts:49`), so the "import note 
from URL" request carries the same `withCredentials` and `X-Requested-With` as 
a call to Zeppelin's own API. A browser rejects a credentialed cross-origin 
response whose `Access-Control-Allow-Origin` is `*`, and that wildcard is what 
public file hosts serve.
    
    Measured in Chromium against local servers reproducing each CORS 
configuration, with the `raw.githubusercontent.com` preflight response checked 
directly:
    
    | target host | dev build | production today | production after this PR |
    |---|---|---|---|
    | rejects `X-Requested-With` at preflight — `raw.githubusercontent.com` 
answers the preflight with 403 | blocked | blocked | blocked |
    | allows `X-Requested-With`, serves `Access-Control-Allow-Origin: *` | 
blocked | works | blocked |
    
    Only the second row changes, and it changes production to match what 
development already does, which is what this ticket asks for.
    
    The underlying problem is that the interceptor does not distinguish 
Zeppelin's own API from an arbitrary URL. The classic UI handles it by 
overriding `withCredentials: false` for exactly this request 
(`zeppelin-web/src/components/note-import/note-import.controller.js:95-97`); 
the new UI has no equivalent. That is a separate defect which predates this 
ticket and already breaks the feature in development builds today.
    
    Scoping the interceptor so that `withCredentials` and `X-Requested-With` 
are applied only to Zeppelin's own API would resolve it, and would not change 
anything this PR does for API calls. I have not filed a ticket for it yet — I 
would rather hear whether that scoping belongs in this PR or in a follow-up.
    
    ### What type of PR is it?
    
    Bug Fix
    
    ### Todos
    
    * [x] - Derive the production clone from the already-credentialed request
    * [x] - Confirm `X-Requested-With` is still added in production builds
    * [x] - Confirm development builds are unaffected
    
    ### What is the Jira issue?
    
    * [ZEPPELIN-6521](https://issues.apache.org/jira/browse/ZEPPELIN-6521)
    
    ### How should this be tested?
    
    There is no unit-test runner for this app to add a spec to: the `zeppelin` 
project in `angular.json` declares only `build`, `serve`, `extract-i18n` and 
`lint`, and `zeppelin-web-angular/src` contains no `.spec.ts`. A Playwright 
test is also a poor fit here, because the local suite runs against `ng serve` 
(`playwright.config.js:16`), where `environment.production` is `false` and the 
affected branch never executes — such a test would pass with or without this 
change unless run in CI mod [...]
    
    **1. Lint**
    
    ```
    cd zeppelin-web-angular && npm run lint
    ```
    
    Exit 0. 15 pre-existing `member-ordering` warnings in 
`projects/zeppelin-visualization`, none in the changed file; `lint:react` 
clean; `prettier --check` reports all files formatted.
    
    **2. `HttpRequest.clone()` inheritance, using the real class**
    
    Running both the old and the new form of the production branch through 
Angular's real `HttpRequest` class:
    
    ```
    development branch    withCredentials=true   X-Requested-With=(none)
    production, before    withCredentials=false  X-Requested-With=XMLHttpRequest
    production, after     withCredentials=true   X-Requested-With=XMLHttpRequest
    ```
    
    **3. Production bundle in a real browser**
    
    `ng build --configuration production` twice — once with this commit and 
once with it reverted, changing nothing else — serving `dist/zeppelin` 
statically and instrumenting the `XMLHttpRequest.prototype.withCredentials` 
setter. `BaseUrlService` derives the REST base from `location`, so the app 
issues its normal bootstrap calls against the static server; they 404, but 
Angular assigns `withCredentials` before `send()`, which is what is being 
observed.
    
    ```
                          API requests   withCredentials assignments   
X-Requested-With
    before this commit    4              []                            
XMLHttpRequest
    after  this commit    4              [true, true, true, true]      
XMLHttpRequest
    ```
    
    `X-Requested-With` is present in both runs, which confirms the production 
branch really executed and that the change does not drop the header.
    
    ### Screenshots (if appropriate)
    
    N/A
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No — see "Scope and related 
issues" for the one behaviour that changes for third-party URL fetches
    * Does this needs documentation? No
    
    
    Closes #5377 from big-cir/ZEPPELIN-6521.
    
    Signed-off-by: Jongyoul Lee <[email protected]>
---
 .../share/note-import/note-import-modal.spec.ts    | 42 ++++++++++++++++++++++
 .../src/app/app-http.interceptor.ts                |  2 +-
 .../app/share/note-import/note-import.component.ts |  8 +++--
 3 files changed, 48 insertions(+), 4 deletions(-)

diff --git 
a/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts 
b/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts
index 229967d719..6361759afd 100644
--- a/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts
+++ b/zeppelin-web-angular/e2e/tests/share/note-import/note-import-modal.spec.ts
@@ -10,6 +10,8 @@
  * limitations under the License.
  */
 
+import { createServer } from 'node:http';
+
 import { test, expect } from '@playwright/test';
 import { HomePage } from '../../../models/home-page';
 import { NoteImportModal } from '../../../models/note-import-modal';
@@ -69,6 +71,46 @@ test.describe('Note Import Modal', () => {
     await expect(noteImportModal.importNoteButton).toBeEnabled();
   });
 
+  test('Given URL tab is selected, When importing from wildcard CORS origin, 
Then response should be readable', async () => {
+    const corsServer = createServer((request, response) => {
+      response.setHeader('Access-Control-Allow-Headers', 'X-Requested-With');
+      response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+      response.setHeader('Access-Control-Allow-Origin', '*');
+      response.setHeader('Connection', 'close');
+
+      if (request.method === 'OPTIONS') {
+        response.writeHead(204);
+        response.end();
+        return;
+      }
+
+      response.setHeader('Content-Type', 'application/json');
+      response.end(JSON.stringify({ name: 'Missing paragraphs' }));
+    });
+
+    await new Promise<void>((resolve, reject) => {
+      corsServer.once('error', reject);
+      corsServer.listen(0, '127.0.0.1', resolve);
+    });
+
+    try {
+      const address = corsServer.address();
+      if (!address || typeof address === 'string') {
+        throw new Error('Failed to bind CORS test server');
+      }
+
+      await noteImportModal.switchToUrlTab();
+      await 
noteImportModal.setImportUrl(`http://127.0.0.1:${address.port}/note.json`);
+      await noteImportModal.clickImportNote();
+
+      await expect(noteImportModal.errorAlert).toHaveText('Invalid JSON');
+    } finally {
+      await new Promise<void>((resolve, reject) => {
+        corsServer.close(error => (error ? reject(error) : resolve()));
+      });
+    }
+  });
+
   test('Given Import Note modal is open, When entering import name, Then name 
should be set', async () => {
     const importName = `Imported Note ${Date.now()}`;
     await noteImportModal.setImportAsName(importName);
diff --git a/zeppelin-web-angular/src/app/app-http.interceptor.ts 
b/zeppelin-web-angular/src/app/app-http.interceptor.ts
index b1e287622a..6a6a4a1853 100644
--- a/zeppelin-web-angular/src/app/app-http.interceptor.ts
+++ b/zeppelin-web-angular/src/app/app-http.interceptor.ts
@@ -28,7 +28,7 @@ export class AppHttpInterceptor implements HttpInterceptor {
   intercept(httpRequest: HttpRequest<any>, next: HttpHandler): 
Observable<HttpEvent<any>> {
     let httpRequestUpdated = httpRequest.clone({ withCredentials: true });
     if (environment.production) {
-      httpRequestUpdated = httpRequest.clone({ setHeaders: { 
'X-Requested-With': 'XMLHttpRequest' } });
+      httpRequestUpdated = httpRequestUpdated.clone({ setHeaders: { 
'X-Requested-With': 'XMLHttpRequest' } });
     }
     return next.handle(httpRequestUpdated).pipe(
       map(event => {
diff --git 
a/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts 
b/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts
index a73627b9ba..ef5fdb7f40 100644
--- a/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts
+++ b/zeppelin-web-angular/src/app/share/note-import/note-import.component.ts
@@ -10,7 +10,7 @@
  * limitations under the License.
  */
 
-import { HttpClient } from '@angular/common/http';
+import { HttpBackend, HttpClient } from '@angular/common/http';
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from 
'@angular/core';
 import { ConfigurationService, MessageService, TicketService } from 
'@zeppelin/services';
 
@@ -37,6 +37,7 @@ export class NoteImportComponent extends 
MessageListenersManager implements OnIn
   errorText?: string;
   importLoading = false;
   wsMaxLimit?: number;
+  private readonly externalHttpClient: HttpClient;
 
   @MessageListener(OP.IMPORT_NOTE)
   noteImported(_: MessageReceiveDataTypeMap[OP.IMPORT_NOTE]) {
@@ -46,7 +47,7 @@ export class NoteImportComponent extends 
MessageListenersManager implements OnIn
   importNote() {
     this.errorText = '';
     this.importLoading = true;
-    this.httpClient.get(this.importUrl ?? '').subscribe(
+    this.externalHttpClient.get(this.importUrl ?? '').subscribe(
       data => {
         this.importLoading = false;
         this.processImportJson(data);
@@ -106,9 +107,10 @@ export class NoteImportComponent extends 
MessageListenersManager implements OnIn
     private configurationService: ConfigurationService,
     private cdr: ChangeDetectorRef,
     private nzModalRef: NzModalRef,
-    private httpClient: HttpClient
+    httpBackend: HttpBackend
   ) {
     super(messageService);
+    this.externalHttpClient = new HttpClient(httpBackend);
   }
 
   async ngOnInit() {

Reply via email to