Merge ~epics-core/epics-base/+git/asLib:as-hostname into ~epics-core/epics-base/+git/epics-base:7.0

Proposed by mdavidsaver
Status: Merged
Approved by: Andrew Johnson
Approved revision: 4dcd6f37c697b1517b5e36ae1b9d130abfcd7699
Merged at revision: c0a04eae219d3f3f8cf9ce9c1507cb31a6149034
Proposed branch: ~epics-core/epics-base/+git/asLib:as-hostname
Merge into: ~epics-core/epics-base/+git/epics-base:7.0
Diff against target: 313 lines (+153/-12) (has conflicts)
8 files modified
documentation/RELEASE_NOTES.html (+45/-0)
modules/database/src/ioc/rsrv/camessage.c (+8/-0)
modules/database/src/ioc/rsrv/caservertask.c (+14/-0)
modules/database/src/ioc/rsrv/server.h (+1/-1)
modules/libcom/src/as/asLib.h (+7/-2)
modules/libcom/src/as/asLibRoutines.c (+34/-6)
modules/libcom/src/iocsh/libComRegister.c (+6/-0)
modules/libcom/test/aslibtest.c (+38/-3)
Conflict in documentation/RELEASE_NOTES.html
Reviewer Review Type Date Requested Status
Andrew Johnson Approve
mdavidsaver Approve
Review via email: mp+358822@code.launchpad.net

Description of the change

Lets give CA Access Security some teeth. At least as far as is possible w/o protocol changes.

Add a flag which replaces use of CA client provided host name strings with the peer IP address, which isn't so trivially forged. This is coupled with a change to asLib to cause a reverse lookup of hostnames found in ACF files. So the "names" stored as all IP addresses. This is done when an ACF file load, so subsequent DNS changes wouldn't be picked up until/unless the ACF file is reloaded.

As a first attempt, the switch to change behavior is a global variable 'asUseIP'. This was quick to implement as I expect ANJ will have other ideas.

To post a comment you must log in.
Revision history for this message
Andrew Johnson (anj) wrote :

Calls for a parallel change to PCAS, once this has been merged.

Keep with a variable for now, consider adding an environment variable for default setting once we've had some experience with using this.

Test what happens if you switch it on/off at runtime.

Documentation – update the chapter of the AppDevGuide, as well as a Release Notes entry ("experimental"?).

Revision history for this message
mdavidsaver (mdavidsaver) wrote :

I've pushed a change which addresses reviewer comments. A fresh clone pull it down, but the LP web frontend doesn't show it in the as-hostname branch history. The commit can be directly referenced. Not sure what is going on...

https://git.launchpad.net/~epics-core/epics-base/+git/asLib/commit/?id=73cdea5517d625243ba149abf4a1368fbae8fe81

https://github.com/mdavidsaver/epics-base/commit/73cdea5517d625243ba149abf4a1368fbae8fe81

Revision history for this message
Andrew Johnson (anj) wrote :

Weird, looks like a Launchpad bug. I just changed Launchpad's "default branch" setting for that repo to be refs/heads/as-hostname although doing that didn't trigger any updates. It was set to refs/heads/master but that branch doesn't actually exist, which might be why LP got confused. I'm about to push a change expanding the Release Notes entry in the hopes that a commit to the repo's default branch might trigger Launchpad to wake up; we'll see whether it does shortly.

Question: I think we said that if a host-name cannot be found, this will cause the IOC to start up with all access disabled. Is this the behavior that we actually want? Wouldn't it be better to allow other hosts that can be resolved to still connect? I suspect our operational ASC files have any number of no-longer-valid host names in them, and here the act of removing a long-disused host name from our DNS is something that could our IT group might do at any time, so it shouldn't prevent our control system from starting up (frankly that would be an operational nightmare). If you change this behavior you should also adjust the Release Note to match (new consequence #2).

Revision history for this message
mdavidsaver (mdavidsaver) wrote :

> if a host-name cannot be found, this will cause the IOC to start up with all access disabled. Is this the behavior that we actually want?

That's a fair point. As I think about it, there is no reason to fail the whole load if one host doesn't lookup. It would be easy enough to fill in "unresolved" instead of an IP, print a warning, and continue.

(personally, I'd like to eliminate read access restrictions all together)

> something that could our IT group might do at any time

To some extent, this could be mitigated by including both hostname and IP for some important hosts. eg, hosts which can trigger ACF reload.

Ultimately though, turning this feature on implies an acceptance of this risk.

review: Needs Fixing
Revision history for this message
mdavidsaver (mdavidsaver) wrote :

Name resolution now fails soft.

Revision history for this message
Andrew Johnson (anj) wrote :

Since you changed it to fail soft, the release note needs updating to match. The wording of the second list bullet item is now wrong, and the end of the preceding text paragraph might need tweaking as well, not sure about that.

Revision history for this message
mdavidsaver (mdavidsaver) wrote :

Release notes updated.

review: Approve
Revision history for this message
Andrew Johnson (anj) wrote :

AI on AJ to review soon.

Revision history for this message
Bruce Hill (bhill) wrote :

Successfully tested this patch w/ pvAccess gwdev branch and pva2pva d7314ea from mdavidsaver.

Tested IOC and ca-gateway instances w/ asCheckClientIP=0 and asCheckClientIP=1.
ca-gateway was based on R2-1-1-0 with inline code to set asCheckClientIP.

No changes were needed to pcas version 4.13.2.

With asCheckClientIP=0, a hacked caput can bypass ASG RULES using hostnames.
With asCheckClientIP=1, caput can only spoof username

For pvput and IOC testing:
With asCheckClientIP=0, All ASG RULES using HAG deny write access.
With asCheckClientIP=1, pvput works same as CA for all UAG and HAG based ASG RULES. (Didn't test variables in RULES or spoofing username in pvput.)

Also tested w/ new p4p gateway as a client while p4p gateway is running it's own access security.

Revision history for this message
Bruce Hill (bhill) wrote :

Re the initial default for asCheckClientIP, I recommend it be set to 1.

Defaulting to 0 only helps those who run w/o DNS yet also want to run EPICS 7 IOCs
but continue to use CA instead of PVA to modify their access secured PVs.
They also have an easy workaround by using IP addr in their acf files.

Meanwhile, every site who tries to support access security w/ EPICS 7 could easily
miss the need to set asCheckClientIP in order to make pvAccess support HAG based rules.

It also has the benefit of closing the CA spoofed hostname vulnerability.

Revision history for this message
Ernest Williams (ernesto-slac) wrote :

I agree

Sent from my iPhone

> On Aug 13, 2019, at 11:10 PM, Bruce Hill via Core-talk <email address hidden> wrote:
>
> Re the initial default for asCheckClientIP, I recommend it be set to 1.
>
> Defaulting to 0 only helps those who run w/o DNS yet also want to run EPICS 7 IOCs
> but continue to use CA instead of PVA to modify their access secured PVs.
> They also have an easy workaround by using IP addr in their acf files.
>
> Meanwhile, every site who tries to support access security w/ EPICS 7 could easily
> miss the need to set asCheckClientIP in order to make pvAccess support HAG based rules.
>
> It also has the benefit of closing the CA spoofed hostname vulnerability.
> --
> https://code.launchpad.net/~epics-core/epics-base/+git/asLib/+merge/358822
> Your team EPICS Core Developers is subscribed to branch epics-base:7.0.

Revision history for this message
Andrew Johnson (anj) wrote :

Minor Q: An IPv4 address represented as an ASCII dotted-quad needs no more than 16 chars including the string terminator. Why are you (consistently) allocating 24? With a port number it would need 22 chars, and 24 chars would be too short for an IPv6 address. Just wondering...

On the default value of asCheckClientIP, my concern with making it 1 is that this could change the behavior for some existing working systems. If the name for a client listed in an IOC's ASCF is the canonical hostname for the client machine but the network path to that IOC comes through a secondary network interface with a different DNS name, a previously working ASCF rule will now deny access to that client. Admittedly this is an unusual situation but it's by no means impossible, and the code currently has no way to detect when it happens (it could, but Michael has better things to do with his time than implement that). If we do change the default the release notes would need changing to match, and they should probably have something in bold or red to get people to notice.

Approved.

review: Approve
Revision history for this message
mdavidsaver (mdavidsaver) wrote :

I'm planning to go ahead and merge with a default of asCheckClientIP=0. Changing this can be a separate discussion.

> Why are you (consistently) allocating 24?

Force of habit? I can't recall.

> default value of asCheckClientIP

Can we agree that the desired final solution is defaulting to asCheckClientIP=1 ?

> Admittedly this is an unusual situation

Yup, given the number of sites not using ACF at all.

Is it worth my arguing that any site bothering with access control
would likely welcome stronger authentication?

> canonical hostname ... a secondary network interface with a different DNS name
> ... it could ...

How could this be done? I can't think of a way.

> has better things to do

Granted. It would still be nice to document a possible course of action in case someone is motivated.

Revision history for this message
Andrew Johnson (anj) wrote :

> Can we agree that the desired final solution is defaulting to asCheckClientIP=1 ?

Yes, I'd just like there to be something like a couple of releases where people can try it out before we flip the default.

> Is it worth my arguing that any site bothering with access control would likely welcome stronger authentication?

We use AS here to prevent people from fiddling with PVs that only the experts should be modifying, such as some of our RF system IOCs where a mis-placed put might be able to destroy one of our dwindling supply of RF cavities. I'm totally guessing whether that might be possible, but I do know that our RF IOCs do use it quite extensively, as do our beamline gateways. I agree that stronger authentication is better, and that this will give us stronger authentication.

> How could this be done?

Instead of making this a binary switch, store both the HAG name and the DNS result. Implement the host comparisons using both methods simultaneously and cross-check the results. When the by-name comparison says Match but the by-DNS says No there's either someone trying to impersonate a different client machine or this could be a multiple-interface name issue. You aren't modifying the actual host comparison code at all here though, so doing this would need more extensive changes to asLib.

Similarly larger changes would be needed to allow the use of IP address ranges in the ASCF (e.g. 192.168.12.16/28 say) which I could see as being useful to some (and would need more than 16 chars to store).

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/documentation/RELEASE_NOTES.html b/documentation/RELEASE_NOTES.html
2index df78360..39d101b 100644
3--- a/documentation/RELEASE_NOTES.html
4+++ b/documentation/RELEASE_NOTES.html
5@@ -30,6 +30,7 @@ release.</p>
6
7 -->
8
9+<<<<<<< documentation/RELEASE_NOTES.html
10 <h1 align="center">EPICS Release 7.0.2.2</h1>
11
12 <h3>Build System changes</h3>
13@@ -127,6 +128,50 @@ than it used to be.</p>
14
15
16 <h1 align="center">EPICS Release 7.0.2</h1>
17+=======
18+<h3>Channel Access Security: Check Hostname Against DNS</h3>
19+
20+<p>Host names given in a <tt>HAG</tt> entry of an IOC's Access Security
21+Configuration File (ACF) have to date been compared against the hostname
22+provided by the CA client at connection time, which may or may not be the actual
23+name of that client. This allows rogue clients to pretend to be a different
24+host, and the IOC would believe them.</p>
25+
26+<p>An option is now available to cause an IOC to ask its operating system to
27+look up the IP address of any hostnames listed in its ACF (which will normally
28+be done using the DNS or the <tt>/etc/hosts</tt> file). The IOC will then
29+compare the resulting IP address against the client's actual IP address when
30+checking access permissions at connection time. This name resolution is performed
31+at ACF file load time, which has a few consequences:</p>
32+
33+<ol>
34+
35+<li>If the DNS is slow when the names are resolved this will delay the process
36+of loading the ACF file.</li>
37+
38+<li>If a host name cannot be resolved the IOC will proceed, but this host name
39+will never be matched.</li>
40+
41+<li>Any changes in the hostname to IP address mapping will not be picked up by
42+the IOC unless and until the ACF file gets reloaded.</li>
43+
44+</ol>
45+
46+<p>Optionally, IP addresses may be added instead of, or in addition to, host names
47+in the ACF file.</p>
48+
49+<p>This feature can be enabled before <tt>iocInit</tt> with</p>
50+
51+<blockquote><pre>
52+var("asCheckClientIP",1)
53+</pre></blockquote>
54+
55+<p>or with the VxWorks target shell use</p>
56+
57+<blockquote><pre>
58+asCheckClientIP = 1
59+</pre></blockquote>
60+>>>>>>> documentation/RELEASE_NOTES.html
61
62 <h3>Launchpad Bugs</h3>
63
64diff --git a/modules/database/src/ioc/rsrv/camessage.c b/modules/database/src/ioc/rsrv/camessage.c
65index 72a4b17..f54bb48 100644
66--- a/modules/database/src/ioc/rsrv/camessage.c
67+++ b/modules/database/src/ioc/rsrv/camessage.c
68@@ -861,6 +861,14 @@ static int host_name_action ( caHdrLargeArray *mp, void *pPayload,
69 return RSRV_ERROR;
70 }
71
72+ /* after all validation */
73+ if(asCheckClientIP) {
74+
75+ DLOG (2, ( "CAS: host_name_action for \"%s\" ignores client provided host name\n",
76+ client->pHostName ) );
77+ return RSRV_OK;
78+ }
79+
80 /*
81 * user name will not change if there isnt enough memory
82 */
83diff --git a/modules/database/src/ioc/rsrv/caservertask.c b/modules/database/src/ioc/rsrv/caservertask.c
84index f377d83..7a9ae63 100644
85--- a/modules/database/src/ioc/rsrv/caservertask.c
86+++ b/modules/database/src/ioc/rsrv/caservertask.c
87@@ -1421,6 +1421,20 @@ struct client *create_tcp_client (SOCKET sock , const osiSockAddr *peerAddr)
88 }
89
90 client->addr = peerAddr->ia;
91+ if(asCheckClientIP) {
92+ epicsUInt32 ip = ntohl(client->addr.sin_addr.s_addr);
93+ client->pHostName = malloc(24);
94+ if(!client->pHostName) {
95+ destroy_client ( client );
96+ return NULL;
97+ }
98+ epicsSnprintf(client->pHostName, 24,
99+ "%u.%u.%u.%u",
100+ (ip>>24)&0xff,
101+ (ip>>16)&0xff,
102+ (ip>>8)&0xff,
103+ (ip>>0)&0xff);
104+ }
105
106 /*
107 * see TCP(4P) this seems to make unsolicited single events much
108diff --git a/modules/database/src/ioc/rsrv/server.h b/modules/database/src/ioc/rsrv/server.h
109index 4d502f7..6392c69 100644
110--- a/modules/database/src/ioc/rsrv/server.h
111+++ b/modules/database/src/ioc/rsrv/server.h
112@@ -86,7 +86,7 @@ typedef struct client {
113 ELLLIST chanList;
114 ELLLIST chanPendingUpdateARList;
115 ELLLIST putNotifyQue;
116- struct sockaddr_in addr;
117+ struct sockaddr_in addr; /* peer address, TCP only */
118 epicsTimeStamp time_at_last_send;
119 epicsTimeStamp time_at_last_recv;
120 void *evuser;
121diff --git a/modules/libcom/src/as/asLib.h b/modules/libcom/src/as/asLib.h
122index 261e5ed..528ce6e 100644
123--- a/modules/libcom/src/as/asLib.h
124+++ b/modules/libcom/src/as/asLib.h
125@@ -21,6 +21,11 @@
126 extern "C" {
127 #endif
128
129+/* 0 - Use (unverified) client provided host name string.
130+ * 1 - Use actual client IP address. HAG() are resolved to IPs at ACF load time.
131+ */
132+epicsShareExtern int asCheckClientIP;
133+
134 typedef struct asgMember *ASMEMBERPVT;
135 typedef struct asgClient *ASCLIENTPVT;
136 typedef int (*ASINPUTFUNCPTR)(char *buf,int max_size);
137@@ -165,8 +170,8 @@ typedef struct uag{
138 } UAG;
139 /*Defs for Host Access Groups*/
140 typedef struct{
141- ELLNODE node;
142- char *host;
143+ ELLNODE node;
144+ char host[1];
145 } HAGNAME;
146 typedef struct hag{
147 ELLNODE node;
148diff --git a/modules/libcom/src/as/asLibRoutines.c b/modules/libcom/src/as/asLibRoutines.c
149index 3f5713e..ab0bf50 100644
150--- a/modules/libcom/src/as/asLibRoutines.c
151+++ b/modules/libcom/src/as/asLibRoutines.c
152@@ -15,6 +15,8 @@
153 #include <ctype.h>
154
155 #define epicsExportSharedSymbols
156+#include "osiSock.h"
157+#include "epicsTypes.h"
158 #include "epicsStdio.h"
159 #include "dbDefs.h"
160 #include "epicsThread.h"
161@@ -27,6 +29,8 @@
162 #include "postfix.h"
163 #include "asLib.h"
164
165+int asCheckClientIP;
166+
167 static epicsMutexId asLock;
168 #define LOCK epicsMutexMustLock(asLock)
169 #define UNLOCK epicsMutexUnlock(asLock)
170@@ -1203,14 +1207,38 @@ static HAG *asHagAdd(const char *hagName)
171 static long asHagAddHost(HAG *phag,const char *host)
172 {
173 HAGNAME *phagname;
174- int len, i;
175
176 if (!phag) return 0;
177- len = strlen(host);
178- phagname = asCalloc(1, sizeof(HAGNAME) + len + 1);
179- phagname->host = (char *)(phagname + 1);
180- for (i = 0; i < len; i++) {
181- phagname->host[i] = (char)tolower((int)host[i]);
182+ if(!asCheckClientIP) {
183+ size_t i, len = strlen(host);
184+ phagname = asCalloc(1, sizeof(HAGNAME) + len);
185+ for (i = 0; i < len; i++) {
186+ phagname->host[i] = (char)tolower((int)host[i]);
187+ }
188+
189+ } else {
190+ struct sockaddr_in addr;
191+ epicsUInt32 ip;
192+
193+ if(aToIPAddr(host, 0, &addr)) {
194+ static const char unresolved[] = "unresolved:";
195+
196+ errlogPrintf("ACF: Unable to resolve host '%s'\n", host);
197+
198+ phagname = asCalloc(1, sizeof(HAGNAME) + sizeof(unresolved)-1+strlen(host));
199+ strcpy(phagname->host, unresolved);
200+ strcat(phagname->host, host);
201+
202+ } else {
203+ ip = ntohl(addr.sin_addr.s_addr);
204+ phagname = asCalloc(1, sizeof(HAGNAME) + 24);
205+ epicsSnprintf(phagname->host, 24,
206+ "%u.%u.%u.%u",
207+ (ip>>24)&0xff,
208+ (ip>>16)&0xff,
209+ (ip>>8)&0xff,
210+ (ip>>0)&0xff);
211+ }
212 }
213 ellAdd(&phag->list, &phagname->node);
214 return 0;
215diff --git a/modules/libcom/src/iocsh/libComRegister.c b/modules/libcom/src/iocsh/libComRegister.c
216index b105ea1..8fd5e79 100644
217--- a/modules/libcom/src/iocsh/libComRegister.c
218+++ b/modules/libcom/src/iocsh/libComRegister.c
219@@ -12,6 +12,7 @@
220
221 #define epicsExportSharedSymbols
222 #include "iocsh.h"
223+#include "asLib.h"
224 #include "epicsStdioRedirect.h"
225 #include "epicsString.h"
226 #include "epicsTime.h"
227@@ -393,6 +394,8 @@ static void installLastResortEventProviderCallFunc(const iocshArgBuf *args)
228 installLastResortEventProvider();
229 }
230
231+static iocshVarDef asCheckClientIPDef = {"asCheckClientIP", iocshArgInt, 0};
232+
233 void epicsShareAPI libComRegister(void)
234 {
235 iocshRegister(&dateFuncDef, dateCallFunc);
236@@ -425,4 +428,7 @@ void epicsShareAPI libComRegister(void)
237
238 iocshRegister(&generalTimeReportFuncDef,generalTimeReportCallFunc);
239 iocshRegister(&installLastResortEventProviderFuncDef, installLastResortEventProviderCallFunc);
240+
241+ asCheckClientIPDef.pval = &asCheckClientIP;
242+ iocshRegisterVariable(&asCheckClientIPDef);
243 }
244diff --git a/modules/libcom/test/aslibtest.c b/modules/libcom/test/aslibtest.c
245index 875aa56..4237faf 100644
246--- a/modules/libcom/test/aslibtest.c
247+++ b/modules/libcom/test/aslibtest.c
248@@ -81,8 +81,8 @@ static const char hostname_config[] = ""
249
250 static void testHostNames(void)
251 {
252-
253 testDiag("testHostNames()");
254+ asCheckClientIP = 0;
255
256 testOk1(asInitMem(hostname_config, NULL)==0);
257
258@@ -102,18 +102,53 @@ static void testHostNames(void)
259 testAccess("ro", 0);
260 testAccess("rw", 0);
261
262- setHost("nosuchhost");
263+ setHost("guaranteed.invalid.");
264+
265+ testAccess("invalid", 0);
266+ testAccess("DEFAULT", 0);
267+ testAccess("ro", 0);
268+ testAccess("rw", 0);
269+}
270+
271+static void testUseIP(void)
272+{
273+ testDiag("testUseIP()");
274+ asCheckClientIP = 1;
275+
276+ /* still host names in .acf */
277+ testOk1(asInitMem(hostname_config, NULL)==0);
278+ /* now resolved to IPs */
279+
280+ setUser("testing");
281+ setHost("localhost"); /* will not match against resolved IP */
282+ asAsl = 0;
283+
284+ testAccess("invalid", 0);
285+ testAccess("DEFAULT", 0);
286+ testAccess("ro", 0);
287+ testAccess("rw", 0);
288+
289+ setHost("127.0.0.1");
290+
291+ testAccess("invalid", 0);
292+ testAccess("DEFAULT", 0);
293+ testAccess("ro", 1);
294+ testAccess("rw", 3);
295+
296+ setHost("guaranteed.invalid.");
297
298 testAccess("invalid", 0);
299 testAccess("DEFAULT", 0);
300 testAccess("ro", 0);
301 testAccess("rw", 0);
302 }
303+
304 MAIN(aslibtest)
305 {
306- testPlan(14);
307+ testPlan(27);
308 testSyntaxErrors();
309 testHostNames();
310+ testUseIP();
311 errlogFlush();
312 return testDone();
313 }

Subscribers

People subscribed via source and target branches