mirror of
https://github.com/maputnik/editor.git
synced 2026-07-03 12:37:27 +00:00
issue/910: Fix CORS warning for localhost (#939)
See maplibre/maputnik#910 As per the issue, test the hostname of the for a localhost URL, by 1. Domain - localhost 2. IPv4 localhost subnet - 127.0.0.1/8 3. IPv6 localhost - [::1] ## Launch Checklist <!-- Thanks for the PR! Feel free to add or remove items from the checklist. --> - [x] Briefly describe the changes in this PR. - [x] Link to related issues. - [x] Include before/after visuals or gifs if this PR includes visual changes. - [x] Write tests for all new functionality. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. --------- Co-authored-by: zstadler <zeev.stadler@gmail.com> Co-authored-by: Harel M <harel.mazor@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
- Fix Firefox open file that stopped working due to react upgrade
|
- Fix Firefox open file that stopped working due to react upgrade
|
||||||
- Fix issue with missing bottom error panel
|
- Fix issue with missing bottom error panel
|
||||||
- Fixed headers in left panes (Layers list and Layer editor) to remain visible when scrolling
|
- Fixed headers in left panes (Layers list and Layer editor) to remain visible when scrolling
|
||||||
|
- Fix error when using a source from localhost
|
||||||
- _...Add new stuff here..._
|
- _...Add new stuff here..._
|
||||||
|
|
||||||
## 3.0.0
|
## 3.0.0
|
||||||
|
|||||||
+20
-44
@@ -3,55 +3,31 @@ import InputString from "./InputString";
|
|||||||
import SmallError from "./SmallError";
|
import SmallError from "./SmallError";
|
||||||
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
|
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
|
||||||
import { type TFunction } from "i18next";
|
import { type TFunction } from "i18next";
|
||||||
|
import { ErrorType, validate } from "../libs/urlopen";
|
||||||
|
|
||||||
function validate(url: string, t: TFunction): JSX.Element | undefined {
|
function errorTypeToJsx(errorType: ErrorType | undefined, t: TFunction): JSX.Element | undefined {
|
||||||
if (url === "") {
|
switch (errorType) {
|
||||||
return;
|
case ErrorType.EmptyHttpsProtocol:
|
||||||
}
|
return (
|
||||||
|
|
||||||
let error;
|
|
||||||
const getProtocol = (url: string) => {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
return urlObj.protocol;
|
|
||||||
}
|
|
||||||
catch (_err) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const protocol = getProtocol(url);
|
|
||||||
const isSsl = window.location.protocol === "https:";
|
|
||||||
|
|
||||||
if (!protocol) {
|
|
||||||
if (isSsl) {
|
|
||||||
error = (
|
|
||||||
<SmallError>
|
<SmallError>
|
||||||
<Trans t={t}>Must provide protocol: <code>https://</code></Trans>
|
<Trans t={t}>Must provide protocol: <code>https://</code></Trans>
|
||||||
</SmallError>
|
</SmallError>
|
||||||
);
|
);
|
||||||
} else {
|
case ErrorType.EmptyHttpOrHttpsProtocol:
|
||||||
error = (
|
return (
|
||||||
<SmallError>
|
<SmallError>
|
||||||
<Trans t={t}>Must provide protocol: <code>http://</code> or <code>https://</code></Trans>
|
<Trans t={t}>Must provide protocol: <code>http://</code> or <code>https://</code></Trans>
|
||||||
</SmallError>
|
</SmallError>
|
||||||
);
|
);
|
||||||
}
|
case ErrorType.CorsError:
|
||||||
|
return (
|
||||||
|
<SmallError>
|
||||||
|
<Trans t={t}>CORS policy won't allow fetching resources served over http from https, use a <code>https://</code> domain</Trans>
|
||||||
|
</SmallError>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
else if (
|
|
||||||
protocol &&
|
|
||||||
protocol === "http:" &&
|
|
||||||
window.location.protocol === "https:"
|
|
||||||
) {
|
|
||||||
error = (
|
|
||||||
<SmallError>
|
|
||||||
<Trans t={t}>
|
|
||||||
CORS policy won't allow fetching resources served over http from https, use a <code>https://</code> domain
|
|
||||||
</Trans>
|
|
||||||
</SmallError>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldUrlProps = {
|
export type FieldUrlProps = {
|
||||||
@@ -71,7 +47,7 @@ export type FieldUrlProps = {
|
|||||||
type InputUrlInternalProps = FieldUrlProps & WithTranslation;
|
type InputUrlInternalProps = FieldUrlProps & WithTranslation;
|
||||||
|
|
||||||
type InputUrlState = {
|
type InputUrlState = {
|
||||||
error?: React.ReactNode
|
error?: ErrorType
|
||||||
};
|
};
|
||||||
|
|
||||||
class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> {
|
class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> {
|
||||||
@@ -82,20 +58,20 @@ class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlSt
|
|||||||
constructor (props: InputUrlInternalProps) {
|
constructor (props: InputUrlInternalProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
error: validate(props.value, props.t),
|
error: validate(props.value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onInput = (url: string) => {
|
onInput = (url: string) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
error: validate(url, this.props.t),
|
error: validate(url),
|
||||||
});
|
});
|
||||||
if (this.props.onInput) this.props.onInput(url);
|
if (this.props.onInput) this.props.onInput(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange = (url: string) => {
|
onChange = (url: string) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
error: validate(url, this.props.t),
|
error: validate(url),
|
||||||
});
|
});
|
||||||
this.props.onChange(url);
|
this.props.onChange(url);
|
||||||
};
|
};
|
||||||
@@ -109,7 +85,7 @@ class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlSt
|
|||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
aria-label={this.props["aria-label"]}
|
aria-label={this.props["aria-label"]}
|
||||||
/>
|
/>
|
||||||
{this.state.error}
|
{errorTypeToJsx(this.state.error, this.props.t)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { validate, ErrorType } from "./urlopen";
|
||||||
|
|
||||||
|
// Mock window.location if not in browser environment
|
||||||
|
const mockLocation = {
|
||||||
|
protocol: "http:",
|
||||||
|
hostname: "localhost",
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(global, "window", {
|
||||||
|
value: {
|
||||||
|
location: mockLocation,
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validate", () => {
|
||||||
|
let originalProtocol: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Save original protocol
|
||||||
|
originalProtocol = window.location.protocol;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original protocol
|
||||||
|
Object.defineProperty(window.location, "protocol", {
|
||||||
|
writable: true,
|
||||||
|
value: originalProtocol,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when URL is empty", () => {
|
||||||
|
it("should return ErrorType.None", () => {
|
||||||
|
expect(validate("")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when window.location.protocol is https:", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window.location, "protocol", {
|
||||||
|
writable: true,
|
||||||
|
value: "https:",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return EmptyHttpsProtocol when URL has no protocol", () => {
|
||||||
|
expect(validate("example.com")).toBe(ErrorType.EmptyHttpsProtocol);
|
||||||
|
expect(validate("www.example.com/path")).toBe(ErrorType.EmptyHttpsProtocol);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return None for valid https URLs", () => {
|
||||||
|
expect(validate("https://example.com")).toBe(ErrorType.None);
|
||||||
|
expect(validate("https://www.example.com/path")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return CorsError for http URLs pointing to non-local hosts", () => {
|
||||||
|
expect(validate("http://example.com")).toBe(ErrorType.CorsError);
|
||||||
|
expect(validate("http://api.example.com/endpoint")).toBe(ErrorType.CorsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return None for http URLs pointing to localhost", () => {
|
||||||
|
expect(validate("http://localhost")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://localhost:3000")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://127.0.0.1")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://127.0.0.1:8080")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://127.255.255.255")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return None for http URLs pointing to IPv6 localhost", () => {
|
||||||
|
expect(validate("http://[::1]")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://[::1]:3000")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return None for other protocols", () => {
|
||||||
|
expect(validate("ftp://example.com")).toBe(ErrorType.None);
|
||||||
|
expect(validate("ws://example.com")).toBe(ErrorType.None);
|
||||||
|
expect(validate("wss://example.com")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when window.location.protocol is http:", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window.location, "protocol", {
|
||||||
|
writable: true,
|
||||||
|
value: "http:",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return EmptyHttpOrHttpsProtocol when URL has no protocol", () => {
|
||||||
|
expect(validate("example.com")).toBe(ErrorType.EmptyHttpOrHttpsProtocol);
|
||||||
|
expect(validate("www.example.com/path")).toBe(ErrorType.EmptyHttpOrHttpsProtocol);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return None for valid http URLs", () => {
|
||||||
|
expect(validate("http://example.com")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://www.example.com/path")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return None for valid https URLs", () => {
|
||||||
|
expect(validate("https://example.com")).toBe(ErrorType.None);
|
||||||
|
expect(validate("https://www.example.com/path")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return None for localhost URLs", () => {
|
||||||
|
expect(validate("http://localhost")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://127.0.0.1")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle URLs with ports", () => {
|
||||||
|
Object.defineProperty(window.location, "protocol", {
|
||||||
|
writable: true,
|
||||||
|
value: "https:",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validate("https://example.com:8443")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://example.com:8080")).toBe(ErrorType.CorsError);
|
||||||
|
expect(validate("http://localhost:3000")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle URLs with paths and query strings", () => {
|
||||||
|
Object.defineProperty(window.location, "protocol", {
|
||||||
|
writable: true,
|
||||||
|
value: "https:",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validate("https://example.com/path?query=value")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://example.com/path?query=value")).toBe(ErrorType.CorsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed URLs that cannot be parsed", () => {
|
||||||
|
Object.defineProperty(window.location, "protocol", {
|
||||||
|
writable: true,
|
||||||
|
value: "https:",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validate("not a url at all")).toBe(ErrorType.EmptyHttpsProtocol);
|
||||||
|
expect(validate("://")).toBe(ErrorType.EmptyHttpsProtocol);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle localhost variations case-insensitively", () => {
|
||||||
|
Object.defineProperty(window.location, "protocol", {
|
||||||
|
writable: true,
|
||||||
|
value: "https:",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validate("http://LOCALHOST")).toBe(ErrorType.None);
|
||||||
|
expect(validate("http://LocalHost:3000")).toBe(ErrorType.None);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,3 +25,45 @@ export async function loadStyleUrl(styleUrl: string): Promise<StyleSpecification
|
|||||||
return style.emptyStyle;
|
return style.emptyStyle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const enum ErrorType {
|
||||||
|
None,
|
||||||
|
EmptyHttpsProtocol,
|
||||||
|
EmptyHttpOrHttpsProtocol,
|
||||||
|
CorsError
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProtocolSafe(url: string): { protocol?: string, isLocal?: boolean } {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const { protocol, hostname } = urlObj;
|
||||||
|
const isLocal = /^(localhost|\[::1\]|127(.[0-9]{1,3}){3})/i.test(hostname);
|
||||||
|
return { protocol, isLocal };
|
||||||
|
}
|
||||||
|
catch (_err) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validate(url: string): ErrorType {
|
||||||
|
if (url === "") {
|
||||||
|
return ErrorType.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { protocol, isLocal } = getProtocolSafe(url);
|
||||||
|
const isSsl = window.location.protocol === "https:";
|
||||||
|
|
||||||
|
if (!protocol && isSsl) {
|
||||||
|
return ErrorType.EmptyHttpsProtocol;
|
||||||
|
}
|
||||||
|
if (!protocol) {
|
||||||
|
return ErrorType.EmptyHttpOrHttpsProtocol;
|
||||||
|
}
|
||||||
|
if (protocol &&
|
||||||
|
protocol === "http:" &&
|
||||||
|
window.location.protocol === "https:" &&
|
||||||
|
!isLocal) {
|
||||||
|
return ErrorType.CorsError;
|
||||||
|
}
|
||||||
|
return ErrorType.None;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user